From 0180b3343188e0b035334b67a551124c61881666 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:55:41 +0100 Subject: [PATCH] Hide pprof routes on demo-mode instances The /debug/pprof handlers are gated on RequireAdmin plus a settings:read scope that session logins skip. The public demo signs every visitor in as the configured admin, so any visitor could fetch goroutine and heap dumps and the process command line, or start CPU profiles and execution traces that load the whole process. DEMO_MODE already hides diagnostics, logs, admin users and manual discovery through the admin-operations policy; pprof was missing from it. The policy now has the demo guard answer /debug/pprof and every path below it with 404 for every method, ahead of route authentication. Instances outside demo mode keep the routes, and PULSE_PPROF_DISABLED still removes them everywhere. TestContract_DemoModeHidesPprofFromDemoAdminSession signs in as the demo admin through the full router, shows the same session still reads /debug/pprof/cmdline below the demo guard, and gets 404 for eight pprof paths through the served handler. On the previous policy GET and HEAD returned pprof output (200, or the root's 301 into the index) and POST got the generic demo 403. A policy test also fails if a literal route the router files register under /debug/, with or without a method prefix, escapes the policy for any common method. --- .../v6/internal/subsystems/agent-lifecycle.md | 8 ++ .../v6/internal/subsystems/api-contracts.md | 17 ++++ .../internal/subsystems/storage-recovery.md | 8 ++ internal/api/contract_test.go | 94 +++++++++++++++++++ internal/api/demo_mode_operations.go | 28 +++++- internal/api/demo_mode_operations_test.go | 61 ++++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a2ee20031e..e808377c29 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8935,3 +8935,11 @@ like any other write instead of always reaching its handler. Agent command websocket handshakes to `/api/agent/ws` are HTTP/1.1 `GET` requests, the only transport its gorilla upgrader serves, and still connect on a demo instance. No agent registration, enrolment, install, update or removal path changed. + +### Demo mode hides the pprof routes + +`internal/api/demo_mode_operations.go` changed only so the demo guard on a +`DEMO_MODE` instance answers `/debug/pprof` and every path below it with `404` +for every method. No agent route lives under `/debug/`, so agent registration, +enrolment, install, update, removal, report ingest and `/api/agent/ws` +handshakes are unchanged on a demo instance. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index f0d848dbfd..0062bd7e0c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7632,6 +7632,23 @@ read-only demo account. That same hidden read-side boundary includes `GET` and `HEAD` reads for `/api/admin/users` and manual discovery at `/api/discover`; public demo mode may block writes generically, but it must not reveal that admin-user inventory or manual-discovery read routes exist. +The same policy hides the Go runtime profiling family for every method: on a +`DEMO_MODE` instance the demo guard answers `/debug/pprof`, `/debug/pprof/` +and every path below it with `404`, ahead of route authentication. +`registerDebugRoutes` gates those handlers on `RequireAdmin` plus a +`settings:read` scope that session logins skip, so a demo that signs visitors +in as the configured admin would otherwise hand every visitor goroutine and +heap dumps, the process command line, and CPU profiles, execution traces and +forced garbage collection that cost the whole process. `PULSE_PPROF_DISABLED` +still removes the routes on any instance. +`TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` fails when a literal +route the parsed router files register under `/debug/`, with or without a +method prefix, escapes that policy for any common method. +`TestContract_DemoModeHidesPprofFromDemoAdminSession` in +`internal/api/contract_test.go` signs in as that admin through the full +router, proves the same session still reads `/debug/pprof/cmdline` below the +demo guard, and gets `404` through it for eight pprof paths with `GET`, +`HEAD`, `OPTIONS` and `POST`. That generic write block keys on the request method alone. The demo middleware admits `GET`, `HEAD` and `OPTIONS` plus the login, logout, AI execute and OIDC login/callback allowlist; HTTP/1.1 websocket handshakes for diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d1199c9224..558810ae9b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6877,3 +6877,11 @@ allowlist it gets the generic demo `403`, or `404` on a hidden route, and that includes config import at `/api/config/import`. HTTP/1.1 websocket handshakes are `GET` requests and still connect. No storage, retention, backup, migration or recovery path is added or moved. + +### Demo mode hides the pprof routes + +`internal/api/demo_mode_operations.go` adds the Go runtime profiling family +(`/debug/pprof` and every path below it, every method) to the public-demo +hidden routes, so the demo guard on a `DEMO_MODE` instance answers it with +`404`. That removes a diagnostic read surface on demo instances only; no +storage, retention, backup, migration or recovery path is added or moved. diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 953ed0fba2..a6dc98a6ff 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -9212,6 +9212,11 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { {method: http.MethodGet, path: "/api/discover/"}, {method: http.MethodHead, path: "/api/discover"}, {method: http.MethodGet, path: licensePurchaseStartPath}, + {method: http.MethodGet, path: "/debug/pprof"}, + {method: http.MethodGet, path: "/debug/pprof/"}, + {method: http.MethodGet, path: "/debug/pprof/heap"}, + {method: http.MethodPost, path: "/debug/pprof/symbol"}, + {method: http.MethodOptions, path: "/debug/pprof/trace"}, } for _, tc := range testCases { @@ -9375,6 +9380,95 @@ func TestContract_DemoModeUpgradeHeadersDoNotExemptWrites(t *testing.T) { } } +// The pprof routes are gated only on admin auth and session logins skip token +// scopes, so the public demo's shared login reaches them like any admin. The +// demo guard must answer every pprof path with 404 for that session, whatever +// the method, while the handlers below it stay registered. +func TestContract_DemoModeHidesPprofFromDemoAdminSession(t *testing.T) { + t.Setenv("PULSE_PPROF_DISABLED", "") + + dataDir := t.TempDir() + hashedPass, err := authpkg.HashPassword("demo") + if err != nil { + t.Fatalf("hash password: %v", err) + } + cfg := &config.Config{ + DataPath: dataDir, + ConfigPath: dataDir, + DemoMode: true, + AuthUser: "demo", + AuthPass: hashedPass, + } + + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + cleanupTestRouter(t, router) + server := newIPv4HTTPServer(t, router.Handler()) + t.Cleanup(server.Close) + belowDemoGuard := newIPv4HTTPServer(t, router.mux) + t.Cleanup(belowDemoGuard.Close) + + loginResp, err := http.Post(server.URL+"/api/login", "application/json", strings.NewReader(`{"username":"demo","password":"demo"}`)) + if err != nil { + t.Fatalf("demo login: %v", err) + } + loginResp.Body.Close() + if loginResp.StatusCode != http.StatusOK { + t.Fatalf("demo login status = %d, want %d", loginResp.StatusCode, http.StatusOK) + } + var cookiePairs []string + for _, cookie := range loginResp.Cookies() { + cookiePairs = append(cookiePairs, cookie.Name+"="+cookie.Value) + } + if len(cookiePairs) == 0 { + t.Fatal("expected session cookies after demo login") + } + sessionCookies := strings.Join(cookiePairs, "; ") + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + send := func(baseURL, method, path string) (int, string) { + t.Helper() + req, err := http.NewRequest(method, baseURL+path, nil) + if err != nil { + t.Fatalf("build %s %s: %v", method, path, err) + } + req.Header.Set("Cookie", sessionCookies) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + var payload bytes.Buffer + _, _ = payload.ReadFrom(resp.Body) + return resp.StatusCode, payload.String() + } + + status, body := send(belowDemoGuard.URL, http.MethodGet, "/debug/pprof/cmdline") + if status != http.StatusOK || body == "" { + t.Fatalf("demo session below the demo guard: pprof cmdline status = %d, want %d with a body", status, http.StatusOK) + } + + paths := []string{ + "/debug/pprof", + "/debug/pprof/", + "/debug/pprof/heap?gc=1", + "/debug/pprof/goroutine?debug=2", + "/debug/pprof/cmdline", + "/debug/pprof/profile?seconds=1", + "/debug/pprof/symbol", + "/debug/pprof/trace?seconds=1", + } + methods := []string{http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPost} + for _, path := range paths { + for _, method := range methods { + if status, _ := send(server.URL, method, path); status != http.StatusNotFound { + t.Errorf("demo session %s %s status = %d, want %d", method, path, status, http.StatusNotFound) + } + } + } +} + func TestContract_ReleaseDemoFixtureRuntimeGuardrailsRemainCanonical(t *testing.T) { requiredSnippets := map[string][]string{ "licensing_handlers.go": { diff --git a/internal/api/demo_mode_operations.go b/internal/api/demo_mode_operations.go index bad26c86c4..d661fc2204 100644 --- a/internal/api/demo_mode_operations.go +++ b/internal/api/demo_mode_operations.go @@ -1,6 +1,9 @@ package api -import "net/http" +import ( + "net/http" + "strings" +) var publicDemoAdminOperationsPolicies = []publicDemoCommercialRoutePolicy{ { @@ -43,6 +46,17 @@ var publicDemoAdminOperationsPolicies = []publicDemoCommercialRoutePolicy{ exposure: publicDemoCommercialExposureHidden, matches: readDemoCommercialPath("/api/discover"), }, + // Go runtime profiling. These routes are gated only on admin auth, which + // a demo login that is the configured admin passes, so every visitor could + // otherwise pull goroutine and heap dumps or the command line, or start + // CPU profiles and traces that cost the whole process. Hidden for every + // method, so a write probe such as the POST symbol lookup gets the same + // 404 instead of revealing the route. + { + route: "/debug/pprof", + exposure: publicDemoCommercialExposureHidden, + matches: familyDemoCommercialPath("/debug/pprof"), + }, } func publicDemoAdminOperationsPolicyForRequest( @@ -55,3 +69,15 @@ func publicDemoAdminOperationsPolicyForRequest( } return "", false } + +// familyDemoCommercialPath matches root and every path below it, for any +// method. +func familyDemoCommercialPath(root string) func(*http.Request) bool { + return func(r *http.Request) bool { + if r == nil || r.URL == nil { + return false + } + path := normalizeDemoCommercialPath(r.URL.Path) + return path == root || strings.HasPrefix(path, root+"/") + } +} diff --git a/internal/api/demo_mode_operations_test.go b/internal/api/demo_mode_operations_test.go index 2596013369..907702ccc5 100644 --- a/internal/api/demo_mode_operations_test.go +++ b/internal/api/demo_mode_operations_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -23,6 +24,16 @@ func TestPublicDemoAdminOperationsPolicyForRequest(t *testing.T) { {name: "admin users head", method: http.MethodHead, path: "/api/admin/users"}, {name: "manual discovery read", method: http.MethodGet, path: "/api/discover"}, {name: "manual discovery head", method: http.MethodHead, path: "/api/discover"}, + {name: "pprof root", method: http.MethodGet, path: "/debug/pprof"}, + {name: "pprof index", method: http.MethodGet, path: "/debug/pprof/"}, + {name: "pprof heap with forced gc", method: http.MethodGet, path: "/debug/pprof/heap?gc=1"}, + {name: "pprof goroutine dump", method: http.MethodGet, path: "/debug/pprof/goroutine?debug=2"}, + {name: "pprof cmdline", method: http.MethodGet, path: "/debug/pprof/cmdline"}, + {name: "pprof cpu profile", method: http.MethodGet, path: "/debug/pprof/profile?seconds=30"}, + {name: "pprof trace", method: http.MethodGet, path: "/debug/pprof/trace?seconds=5"}, + {name: "pprof symbol lookup", method: http.MethodPost, path: "/debug/pprof/symbol"}, + {name: "pprof head", method: http.MethodHead, path: "/debug/pprof/allocs"}, + {name: "pprof options", method: http.MethodOptions, path: "/debug/pprof/"}, } for _, tc := range hiddenRoutes { @@ -48,6 +59,7 @@ func TestPublicDemoAdminOperationsPolicyForRequest(t *testing.T) { {name: "runtime capabilities", method: http.MethodGet, path: "/api/license/runtime-capabilities"}, {name: "admin users write", method: http.MethodPost, path: "/api/admin/users"}, {name: "manual discovery write", method: http.MethodPost, path: "/api/discover"}, + {name: "pprof lookalike", method: http.MethodGet, path: "/debug/pprofile"}, } for _, tc := range allowedRoutes { @@ -59,3 +71,52 @@ func TestPublicDemoAdminOperationsPolicyForRequest(t *testing.T) { }) } } + +// Every literal route the router files register under /debug/, with or without +// a method prefix, must stay hidden on the public demo whatever the method, so +// a new debug handler cannot ship reachable to the demo admin session by +// default. +func TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute(t *testing.T) { + literalRoutes, _, _ := parseRouterRoutes(t) + + methods := []string{ + http.MethodGet, + http.MethodHead, + http.MethodOptions, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + } + + var debugRoutes int + for _, route := range literalRoutes { + // ServeMux separates a method from its path with spaces or tabs. + pattern := route + if i := strings.IndexAny(route, " \t"); i > 0 { + if rest := strings.TrimLeft(route[i+1:], " \t"); strings.HasPrefix(rest, "/") { + pattern = rest + } + } + if !strings.HasPrefix(pattern, "/debug/") { + continue + } + debugRoutes++ + paths := []string{pattern} + if strings.HasSuffix(pattern, "/") { + paths = append(paths, pattern+"goroutine") + } + for _, path := range paths { + for _, method := range methods { + req := httptest.NewRequest(method, path, nil) + exposure, ok := publicDemoAdminOperationsPolicyForRequest(req) + if !ok || exposure != publicDemoCommercialExposureHidden { + t.Errorf("%s %s is not hidden on the public demo (matched=%v exposure=%q)", method, path, ok, exposure) + } + } + } + } + if debugRoutes == 0 { + t.Fatal("no /debug/ routes found in the router inventory") + } +}