Repository navigation
Feat: Serve pprof on the stats server when loopback-only - #1603
Conversation
The proxy's memory figures were unreadable: RSS on macOS counts pages the GC has freed but the kernel has not reclaimed, and nothing in the tree could decompose the heap. The stats server now mounts net/http/ pprof under /debug/pprof/ when listener.bind_loopback_only is set — the same gate as the session API's clear — because a heap profile dumps process memory, prompts and tokens included. Cluster deployments, where the stats port is not loopback, keep today's surface unchanged. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 21 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
A page can rebind a DNS name to 127.0.0.1 and have the victim's browser read a loopback port, and a heap profile is process memory. The five /debug/pprof handlers now answer 403 unless the Host is exactly localhost, 127.0.0.1 or ::1 — the same defense, and the same predicate, as the session API's erase guards, copied rather than shared because observe must not import sessionapi. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
clawgenti
left a comment
There was a problem hiding this comment.
Summary
Clean, well-scoped change: net/http/pprof is mounted on the stats server's own mux (not DefaultServeMux), off by default, and enabled only from cmd/cortex via listener.bind_loopback_only. I traced the gate end to end and found no path that serves pprof on a non-loopback bind today. No blocking issues. The suggestions below are defense-in-depth plus a broken doc command.
Security analysis of the gate
ApplyPresetruns last and rewritesstats.addresswithforceLocalhost, so with the flag set every form ends up on127.0.0.1:<port>::9093,0.0.0.0:9093,[::]:9093,[::1]:9093andlocalhost:9093all do. An empty address is defaulted to:9093inLoadbefore that (an empty address passed tonet.Listenwould mean every interface on a random port). An addressSplitHostPortcannot parse comes back fromforceLocalhostunchanged, butnet.Listenrejects it too, so it fails closed.- The listener plan is built from the same
cfgthatWithPprofreads, and the stats server is built once at boot, so a hot reload cannot flip the flag under a server that is already bound. - Only
cmd/cortexpassesWithPprof.cortex-envoyandcortex-praxisimportcore/observebut never enable it. I found no in-repo cluster/Helm config that setsbind_loopback_only: true; the only one isdemos/coding-agent-cpex(a laptop demo). - One side effect worth knowing: importing
net/http/pprofregisters handlers onhttp.DefaultServeMuxin all three binaries. I checked everyhttp.ServerandListenAndServeincmd/andcore/, and none serves the default mux, so nothing is exposed today (see the inline nit).
Author: huang195 (MEMBER, maintainer)
Areas reviewed: Go (core/observe, cmd/cortex, core/config gate path), tests, docs
Agent/IDE config (.claude/.vscode): none (CLAUDE.md is a doc-text edit only)
Commits: 1 commit, all signed-off: yes
CI status: passing (go test ./observe/ and go vet also pass locally)
| if o.tlsBridgeUnread != nil { | ||
| mux.Handle("/tls-bridge/unread", o.tlsBridgeUnread) | ||
| } | ||
| if o.pprof { |
There was a problem hiding this comment.
suggestion: The PR describes this as "the same gate as the session API's clear", but clearRefusal makes three checks and pprof adopts only the last one. Clear also rejects a non-loopback Host (loopbackHost) and any request carrying an Origin header, which is the DNS-rebinding and browser defense (see the comment on logClearRefusal). Without those checks:
- A rebinding page can read
/debug/pprof/*same-origin. - Any web page can fire a simple no-cors GET at
http://127.0.0.1:47602/debug/pprof/trace?seconds=86400or/profile?seconds=.... The stats server sets noWriteTimeout, so pprof puts no cap onseconds. The tracer/profiler runs for as long as the browser keeps the connection open, and it holds the process-wide CPU-profile slot.heap?gc=1in a loop forces repeated GCs.
The data exposed is less sensitive than the PR states (see the comment on line 71), so I'm not treating this as blocking. Still, wrapping the pprof routes in the same Host/Origin check would cost a few lines and make the "same gate" claim true. One way: move loopbackHost into a small shared helper and wrap with func(h http.HandlerFunc) http.HandlerFunc.
| } | ||
| // A heap profile dumps process memory — prompts and tokens included — so it | ||
| // rides the same loopback-only gate as the session API's clear. | ||
| statOpts = append(statOpts, observe.WithPprof(cfg.Listener.BindLoopbackOnly)) |
There was a problem hiding this comment.
suggestion (defense in depth): The gate is the config flag, so it relies on an invariant that lives elsewhere: ApplyPreset has to keep running forceLocalhost over Stats.StatsAddress after every default. This tree has already been bitten by that ordering once (#1564, see the comment in presets.go). It would be more robust to also check the socket that was actually bound, e.g.
statsLn := listeners.take("stats")
pprofOK := cfg.Listener.BindLoopbackOnly && isLoopbackAddr(statsLn.Addr())
statOpts = append(statOpts, observe.WithPprof(pprofOK))Here isLoopbackAddr is addr.(*net.TCPAddr).IP.IsLoopback(). Then a future refactor of the preset ordering, or a new way of setting the stats address, cannot put heap/goroutine/trace endpoints on a wildcard bind. A table test on forceLocalhost covering [::]:p, 0.0.0.0:p, localhost:p and [::1]:p would also pin the half that lives in core/config.
| } | ||
|
|
||
| // WithPprof mounts Go's net/http/pprof handlers under /debug/pprof/. Off unless | ||
| // asked for: a heap profile is a dump of process memory — request bodies and |
There was a problem hiding this comment.
suggestion (accuracy): "A heap profile is a dump of process memory — request bodies and tokens included" isn't right for Go's pprof. A heap profile is sampled allocation sites: stack traces with object counts and byte sizes, plus mapping/build-ID metadata. It contains no object contents, so no prompts, bodies or tokens (the tree uses no pprof.Do labels that could carry values). goroutine?debug=2 prints arguments only as hex words. The more sensitive pieces are cmdline (process argv) and the code-structure and timing information.
Keeping the gate conservative is fine. But the stated rationale is repeated in main.go, CLAUDE.md (two places), docs/laptop-service.md and the test comment, and it will steer later decisions such as whether to enable this in-cluster behind port-forward. I'd reword it to something like "exposes internal code structure, argv, and lets a caller start CPU/trace profiling".
| ``` | ||
|
|
||
| The stats port also serves Go's pprof profiles under `/debug/pprof/` — | ||
| `curl -s http://127.0.0.1:47602/debug/pprof/heap | go tool pprof -top -inuse_space cortex -` |
There was a problem hiding this comment.
suggestion: This command fails as written. go tool pprof doesn't read a profile from stdin, so - is treated as a filename (verified with go1.26.10: -: stat -: no such file or directory / failed to fetch any source profiles). The binary argument isn't needed either, because Go heap profiles carry their own symbols. Simplest form:
go tool pprof -top -inuse_space http://127.0.0.1:47602/debug/pprof/heapor the two-step curl -s --noproxy '*' ... -o /tmp/heap.pb.gz from the PR description. The --noproxy '*' is worth keeping on a laptop whose shell has HTTP_PROXY pointed at cortex.
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "net/http/pprof" |
There was a problem hiding this comment.
nit: The init() of net/http/pprof also registers /debug/pprof/* on http.DefaultServeMux. Because it's imported here, that now happens in cortex, cortex-envoy and cortex-praxis, whatever WithPprof says. Nothing in the tree serves the default mux today (I checked every http.Server and ListenAndServe under cmd/ and core/), so this is latent. But a future http.ListenAndServe(addr, nil) or an http.Server with no Handler would serve ungated pprof on whatever it binds. (x/net/trace, pulled in through gRPC, already does the same thing for /debug/requests.) A one-line comment here noting the side effect would help, and so would a test asserting that no production server uses a nil handler.
| if resp.StatusCode != http.StatusOK { | ||
| t.Fatalf("GET /debug/pprof/heap status = %d, want 200", resp.StatusCode) | ||
| } | ||
| if len(body) == 0 { |
There was a problem hiding this comment.
nit: The PR description says this test pins "a parseable heap profile", but it only checks for a non-empty body. Checking for the gzip magic (body[0] == 0x1f && body[1] == 0x8b), or Content-Type: application/octet-stream, would actually separate a profile from, say, the HTML index or an error page. The off-by-default test could also assert the index page has no /debug/pprof/ link.
|
Addressed the review's should-fix: the five Fails closed: an absent Host (HTTP/1.0) is refused too. Assisted-By: Claude (Anthropic AI) noreply@anthropic.com |
Review round on rossoctl#1603: the Host check alone does not stop a browser, because a simple no-cors GET carries a loopback Host and needs no preflight — browsers always send Origin, so its presence is now a refusal, the erase guards' third check. And the profiles expose less than the first round claimed: a heap profile is sampled allocation sites, not object contents, so the rationale now says what they do expose (stack traces, argv, caller-started CPU/trace profiling). Also fixes a laptop-service command that piped a profile into go tool pprof, which cannot read stdin, and pins the served body as a gzip stream rather than merely non-empty. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
|
Review round pushed as
Full Assisted-By: Claude (Anthropic AI) noreply@anthropic.com |
The proxy got pprof in #1603; the viewer did not, and it has needed it — agentop has been the larger of the two processes more than once (the #1542 stream leak lived here), and nothing on the box could decompose its heap. The viewer has no HTTP server to hang the endpoints on, so AGENTOP_PPROF_ADDR starts one for the viewer's lifetime: AGENTOP_PPROF_ADDR=127.0.0.1:47603 agentop observe go tool pprof http://127.0.0.1:47603/debug/pprof/heap Off unless set: the profiles expose the process's internals, and the viewer's heap holds the session content it is displaying. The handlers carry the same browser defenses as #1603 and the session API's erase guards — a non-loopback Host or any Origin is a 403 — copied rather than shared because this is a different binary. A bind failure is reported on stderr and not fatal. The listener starts and is announced before tea takes the alt screen, where stderr stops reaching the user. Verified live: the listener serves while the TUI runs, go tool pprof reads the heap, and both browser guards answer 403. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Two gaps the pprof pass on a live proxy caught, both still open after the memory fixes in #1604 and #1606: requestModel copied every routed request body. gjson.ParseBytes is Parse(string(body)), so reading the top-level keys for "model" made one full copy of the body per request — ~1GB/hour of churn on a proxy that was mostly idle, third behind tool-prune's copies. #1604 stopped the copy being pinned; this stops it being made. The scan now runs over unsafe.String aliasing the body: safe because gjson never writes what it parses, the body is not written concurrently on the request path, and the only string that escapes the scan is the name, which #1604 already clones. TestRequestModelDoesNotCopyTheBody pins the property: one call on a 4MiB body allocates under 64KiB, where the old code allocated the 4MiB. /debug/pprof/block and /mutex were permanently empty. The handlers from #1603 exist, but the runtime records contention only when asked, and nothing ever called SetBlockProfileRate or SetMutexProfileFraction — the two endpoints answered 200 with zero samples, on the box and on every deployment with loopback-only listeners. WithPprof now turns the recorders on with the handlers, under the same loopback gate. Both cost nothing unless the event they measure happens. TestStatServerPprofEnablesContentionProfiles reads the rates back process-globally: both were left at zero before. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…elpers The loopback-Host predicate existed as three unexported copies (the session API's erase guards, the stats server's pprof endpoints, the agentop viewer) and the guarded pprof mounting as two. core/listener/ httpx now owns both: LoopbackHost and RegisterPprof, with the guard table and heap-profile shape pinned once in httpx's own tests. agentop keeps one wiring test; observe's and sessionapi's existing tests now exercise the shared code through their own servers. Also merges main into the branch: the PR branched before rossoctl#1603 landed, so the observe half of this dedupe had no code to change without it. Fixes review: PR rossoctl#1608 review finding 1 — a security predicate copied per binary drifts when one copy is fixed and the others are not. Files: cmd/agentop/pprof.go, cmd/agentop/pprof_test.go, core/listener/httpx/pprof.go, core/listener/httpx/pprof_test.go, core/listener/httpx/render.go, core/observe/statserver.go, core/sessionapi/clear.go, core/sessionapi/delete_test.go Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…address The Host and Origin guards stop browsers, not a curl that sets Host: localhost, so AGENTOP_PPROF_ADDR=:47603 or 0.0.0.0:47603 would hand the heap — the session content the viewer is displaying — to anyone on the network. startPprof now refuses a bind that did not land on loopback, checked on the bound address rather than the string, and returns the address it landed on so a :0 port is announced as its real port. rossoctl#1603 gates its pprof the same way, on the stats port's bind_loopback_only. Fixes review: clawgenti's approve-with-comments on rossoctl#1608 — the unguarded bind address, the :0 announce, and the missing startPprof test, which the returned address makes possible over a real socket. Files: cmd/agentop/pprof.go, cmd/agentop/pprof_test.go, cmd/agentop/README.md Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
What
Mounts Go's
net/http/pprofunder/debug/pprof/*on the stats server (:9093in-cluster,:47602on a laptop install), enabled only whenlistener.bind_loopback_onlyis set.Why
Nothing in the tree could answer "what is the proxy holding in memory". Measured on the laptop today (2026-10-30): cortex showed 3.4 GB RSS, but ~1.5 GB of that was pages the GC had already freed — macOS counts
MADV_FREEpages as resident until the kernel is pressured. The pressure floor was 1.9 GB, and decomposing that further was impossible with no heap endpoint.go tool pprof -inuse_spaceis the only tool that splits live heap from churn, so the local install now serves it:The gate
A heap profile is a dump of process memory — request bodies, prompts and tokens included. It rides the same
listener.bind_loopback_onlygate assessionapi.WithClearAllowed(the DELETE guards), so:TestStatServerPprofOffByDefaultpins that a server built without the option serves no profile;TestStatServerPprofServesProfilespins that one built with it serves a parseable heap profile. Verified end-to-end on a scratch instance both ways, includinggo tool pprofparsing the served profile.Notes
net/http/pprofis stdlib.docs/laptop-service.mddocuments the laptop usage.Assisted-By: Claude (Anthropic AI) noreply@anthropic.com