diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 629fa45621..caf6712d64 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8961,3 +8961,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 a1e0198619..65e3281bb4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7655,6 +7655,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 a36e0eb283..958c15d174 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6901,3 +6901,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") + } +}