Repository navigation
Feat: Serve pprof on the stats server when loopback-only #1603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ import ( | |
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "net/http/pprof" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: The |
||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/rossoctl/cortex/core/auth" | ||
|
|
@@ -38,6 +40,7 @@ type statServerOpts struct { | |
| reloadStatus http.Handler | ||
| pricingTable http.Handler | ||
| tlsBridgeUnread http.Handler | ||
| pprof bool | ||
| } | ||
|
|
||
| // WithReloadStatus registers a /reload/status handler (typically the | ||
|
|
@@ -64,6 +67,22 @@ func WithTLSBridgeUnread(h http.Handler) Option { | |
| return func(o *statServerOpts) { o.tlsBridgeUnread = h } | ||
| } | ||
|
|
||
| // WithPprof mounts Go's net/http/pprof handlers under /debug/pprof/. Off unless | ||
| // asked for: the profiles expose the process's internals — allocation stack | ||
| // traces, argv (cmdline), and a caller's ability to start CPU profiling and | ||
| // tracing — so the host enables it only where the stats port is loopback-only | ||
| // (cmd/cortex reads listener.bind_loopback_only, the same gate as the session | ||
| // API's clear). The handlers also refuse a non-loopback Host and any request | ||
| // carrying Origin, the erase guards' browser defenses. | ||
| // | ||
| // Side effect of the import: net/http/pprof's init registers /debug/pprof/* on | ||
| // http.DefaultServeMux in every binary linking this package. Nothing in this | ||
| // tree serves that mux — an http.Server built with a nil Handler would, and | ||
| // would expose these ungated, so none may be. | ||
| func WithPprof(enabled bool) Option { | ||
| return func(o *statServerOpts) { o.pprof = enabled } | ||
| } | ||
|
|
||
| // NewStatServer builds the stat HTTP server. configProvider is | ||
| // invoked per /config request and statsProvider per /stats request, | ||
| // so both reflect current state rather than a snapshot captured at | ||
|
|
@@ -87,22 +106,37 @@ func NewStatServer(addr string, configProvider ConfigProvider, statsProvider Sta | |
| if o.tlsBridgeUnread != nil { | ||
| mux.Handle("/tls-bridge/unread", o.tlsBridgeUnread) | ||
| } | ||
| if o.pprof { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: The PR describes this as "the same gate as the session API's clear", but
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 |
||
| pprofGuard := func(h http.HandlerFunc) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| if !loopbackHost(r.Host) { | ||
| http.Error(w, "pprof is served only for a loopback Host", http.StatusForbidden) | ||
| return | ||
| } | ||
| if r.Header.Get("Origin") != "" { | ||
| http.Error(w, "pprof is refused from a browser: the request carries an Origin header", http.StatusForbidden) | ||
| return | ||
| } | ||
| h(w, r) | ||
| } | ||
| } | ||
| mux.HandleFunc("/debug/pprof/", pprofGuard(pprof.Index)) | ||
| mux.HandleFunc("/debug/pprof/cmdline", pprofGuard(pprof.Cmdline)) | ||
| mux.HandleFunc("/debug/pprof/profile", pprofGuard(pprof.Profile)) | ||
| mux.HandleFunc("/debug/pprof/symbol", pprofGuard(pprof.Symbol)) | ||
| mux.HandleFunc("/debug/pprof/trace", pprofGuard(pprof.Trace)) | ||
| } | ||
|
|
||
| indexPage := statServerIndex | ||
| if o.pprof { | ||
| indexPage = strings.Replace(statServerIndex, "{{pprof}}", | ||
| `<li><a href="/debug/pprof/">Go pprof profiles</a></li>`, 1) | ||
| } else { | ||
| indexPage = strings.Replace(statServerIndex, "{{pprof}}", "", 1) | ||
| } | ||
| mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| fmt.Fprint(w, ` | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <body> | ||
| <ul> | ||
| <li><a href="/config">Rossoctl AuthBridge configuration</a></li> | ||
| <li><a href="/stats">Rossoctl AuthBridge statistics</a></li> | ||
| <li><a href="/reload/status">Config reload status</a></li> | ||
| <li><a href="/pricing/table">Pricing table</a> (add <code>?host=<gateway></code> for the rates that endpoint is actually charged)</li> | ||
| <li><a href="/tls-bridge/unread">Programs the TLS bridge is not reading</a></li> | ||
| </ul> | ||
| </body> | ||
| </html>`) | ||
| fmt.Fprint(w, indexPage) | ||
| }) | ||
|
|
||
| return &StatServer{ | ||
|
|
@@ -165,3 +199,36 @@ func (s *StatServer) Shutdown(ctx context.Context) error { | |
| func (s *StatServer) Close() error { | ||
| return s.server.Close() | ||
| } | ||
|
|
||
| // statServerIndex is the / body, with {{pprof}} a slot WithPprof fills. | ||
| const statServerIndex = `<!DOCTYPE html> | ||
| <html> | ||
| <body> | ||
| <ul> | ||
| <li><a href="/config">Rossoctl AuthBridge configuration</a></li> | ||
| <li><a href="/stats">Rossoctl AuthBridge statistics</a></li> | ||
| <li><a href="/reload/status">Config reload status</a></li> | ||
| <li><a href="/pricing/table">Pricing table</a> (add <code>?host=<gateway></code> for the rates that endpoint is actually charged)</li> | ||
| <li><a href="/tls-bridge/unread">Programs the TLS bridge is not reading</a></li> | ||
| {{pprof}} | ||
| </ul> | ||
| </body> | ||
| </html>` | ||
|
|
||
| // loopbackHost reports whether hostport names exactly localhost, 127.0.0.1 or | ||
| // ::1, with or without a port. A twin of sessionapi's unexported loopbackHost | ||
| // (the erase guards): copied rather than shared because observe must not import | ||
| // the session API. A short allowlist, not 127.0.0.0/8 or a localhost suffix — | ||
| // the point is to refuse a name an attacker controls. | ||
| func loopbackHost(hostport string) bool { | ||
| host := hostport | ||
| if h, _, err := net.SplitHostPort(hostport); err == nil { | ||
| host = h | ||
| } | ||
| host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | ||
| switch strings.ToLower(host) { | ||
| case "localhost", "127.0.0.1", "::1": | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (defense in depth): The gate is the config flag, so it relies on an invariant that lives elsewhere:
ApplyPresethas to keep runningforceLocalhostoverStats.StatsAddressafter every default. This tree has already been bitten by that ordering once (#1564, see the comment inpresets.go). It would be more robust to also check the socket that was actually bound, e.g.Here
isLoopbackAddrisaddr.(*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 onforceLocalhostcovering[::]:p,0.0.0.0:p,localhost:pand[::1]:pwould also pin the half that lives in core/config.