A Go service's internet-facing listener answers /debug/pprof/heap although no code registers that route — how, and how do you take it off?
answer
- nobody registered it, so who did?
- two innocent lines, one global
- a nil handler is not an empty handler
- check the dependency graph, not just your files
- pass an explicit mux to every listener
basics
~20 sSomething in the build blank-imports net/http/pprof, whose init registers the handlers on http.DefaultServeMux, and the public server was started with a nil handler — which means exactly that mux. Give the public listener its own http.NewServeMux and serve pprof elsewhere.
solid answer
~40 sTwo facts combine. A blank import of `net/http/pprof` — yours, or one buried in a dependency — registers `/debug/pprof/` and friends on the global `http.DefaultServeMux`. And `http.ListenAndServe(addr, nil)`, or an `http.Server` with no `Handler` set, serves that same global mux. Neither line looks dangerous alone; together they publish the process's command line, goroutine stacks, heap profile and an on-demand CPU burn to anyone who can reach the port. The fix is structural, not a route filter: build the public router with `http.NewServeMux()` and pass it explicitly, so the default mux is never served. If operators still need profiles, register `pprof.Index`, `pprof.Cmdline`, `pprof.Profile`, `pprof.Symbol` and `pprof.Trace` on a second mux bound to `127.0.0.1`. Then audit every listener in the codebase for a nil handler, and the dependency graph for the import.
code
go · 13 lines// public listener: its own mux, never the global default
api := http.NewServeMux()
api.HandleFunc("/v1/items", listItems)
go http.ListenAndServe(":8080", api) // handler is never nil
// operator listener: loopback only, handlers registered explicitly
ops := http.NewServeMux()
ops.HandleFunc("/debug/pprof/", pprof.Index) // also serves heap, goroutine, ...
ops.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
ops.HandleFunc("/debug/pprof/profile", pprof.Profile)
ops.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
ops.HandleFunc("/debug/pprof/trace", pprof.Trace)
log.Println(http.ListenAndServe("127.0.0.1:6060", ops))go deeper
Remember the two halves: a blank import of net/http/pprof fills http.DefaultServeMux, and passing nil as the handler serves that mux. Always pass a mux you created.
Explain why the import can come from a dependency you never chose, and why serving an explicit http.NewServeMux removes the exposure by construction rather than by filtering paths.
Demonstrate the whole loop: confirm the exposure with one request, trace the import through go list -deps, audit every listener for a nil handler, move profiling to a loopback listener, and add a CI check so it does not come back.
Own the class of defect: a rule that no listener passes a nil handler, and a build-time check that names the packages allowed to register on global state anywhere in the organisation's services.
## The two-line accident This exposure is almost never written deliberately. It is assembled from two lines that are individually reasonable. Line one, somewhere in the program or in a package it depends on: ```go import _ "net/http/pprof" ``` Its `init` registers the debug routes on `http.DefaultServeMux`, a package-level global in `net/http`. Line two, in the service's startup: ```go http.ListenAndServe(":8080", nil) ``` A nil handler means `http.DefaultServeMux`. So does an `http.Server` value whose `Handler` field is left zero. The public listener is now serving the same global router the debug handlers were written into. The import does not have to be yours. Any package in the transitive dependency graph that blank-imports `net/http/pprof` has the identical effect, which is why "I never wrote that route" is a true statement and not a defence. ## Why it matters more than it looks The endpoints are read-only, so this is sometimes waved off. It should not be: - `/debug/pprof/cmdline` returns the process's full command line, which frequently carries flags, file paths and occasionally credentials. - `/debug/pprof/goroutine?debug=2` dumps every goroutine's stack, exposing internal package and function names, the shape of the request path, and often argument values. - Heap and allocation profiles map out the code's structure and data sizes in symbol form. - `/debug/pprof/profile` is a request that costs the process a full window of profiling work on demand, and `/debug/pprof/trace` likewise. An unauthenticated caller can loop them. So it is at once an information disclosure and a cheap resource-consumption lever against a production process. ## Confirming it From outside, one request settles it: fetch `/debug/pprof/` on the public address and see whether the index page comes back. From inside the code, the audit is two greps and a read: 1. Find the import. Search your own tree for `net/http/pprof`, then check the dependency graph as well — `go list -deps ./...` prints every package the build pulls in, and `net/http/pprof` appearing there is the answer. 2. Find the nil handler. Every `http.ListenAndServe` and every `http.Server` literal in the service: does it pass a handler explicitly? A nil second argument, or an omitted `Handler` field, is the defect. ## The fix Route-level patching — a middleware that 404s anything starting with `/debug/`, or a proxy rule at the edge — is a mitigation, not a fix. It leaves the process serving the surface to anything that can reach the port directly, and it survives only as long as nobody bypasses that layer. The structural fix is to stop serving the default mux on the public listener: ```go api := http.NewServeMux() api.HandleFunc("/v1/items", listItems) srv := &http.Server{Addr: ":8080", Handler: api} // never nil ``` With an explicit handler, whatever any dependency registered on the global mux is unreachable from the internet by construction — no allowlist to maintain, no ordering to get right. If operators still want live profiles, give them a second listener that is not the public one, and wire the handlers onto it explicitly rather than relying on the global: ```go ops := http.NewServeMux() ops.HandleFunc("/debug/pprof/", pprof.Index) ops.HandleFunc("/debug/pprof/profile", pprof.Profile) // ...cmdline, symbol, trace go http.ListenAndServe("127.0.0.1:6060", ops) ``` Binding to `127.0.0.1` rather than `:6060` matters: the second form listens on every interface, and "it is on an unusual port" is not a control. Reached over a tunnel or a port-forward, loopback is perfectly usable for on-call work. Note that the explicit registration also removes the need for the blank import in your own code — you now import the package for its exported handlers, which makes the dependency visible at the call site instead of hiding it in an import line. ## Making it stay fixed The reason this recurs is that both halves are invisible in review. Two cheap guards help. A check in CI that fails if `net/http/pprof` shows up in the release build's package list catches the accidental dependency import. A lint or a review rule that forbids a nil handler on any listener catches the other half, and is worth having independently — an explicit handler is better style regardless. ## What an interviewer is checking Whether you can trace an unexpected route back to a global, and whether your remedy removes the capability rather than hiding the path. A candidate who answers "block /debug/ at the load balancer" and stops there has mitigated the symptom and left the process exposed to anything inside the perimeter.
- How do you prove which mux a handler landed on during an audit?Work both ends. `go list -deps ./...` shows whether net/http/pprof is in the build at all, including through dependencies. Then read every http.ListenAndServe call and http.Server literal in the service and check that a handler is passed explicitly; a nil second argument or an unset Handler field means the global mux is being served.
- Is blocking /debug/ at the reverse proxy an adequate fix?It helps at one layer, but the process still serves the surface to anything that can reach the port — another pod on the network, a misrouted internal call, a future listener added without the same rule. Keep the edge rule if you like, but make the process itself serve an explicit mux so the capability is gone.
- Why bind the operator listener to 127.0.0.1 rather than to :6060?`:6060` listens on every interface, so the surface is merely moved to a less-known port, which is not a control. Loopback restricts it to processes on the same host, and on-call access still works through an SSH tunnel or port-forward, terminating at localhost.
saying these in an interview costs you the question
- Says a 404 middleware on /debug/ is a complete fix
- Only greps their own files and never the dependency graph
- Argues read-only endpoints cannot be a security problem
- Treats an unusual port as access control
- Cannot explain what a nil handler in ListenAndServe means