Hide pprof routes on demo-mode instances (#2708)

The `/debug/pprof` handlers are gated on `RequireAdmin` plus a
`settings:read` scope that session logins skip. A `DEMO_MODE` instance
that signs visitors in as the configured admin therefore served every
visitor goroutine and heap dumps, the process command line, and 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.

## Change

- `internal/api/demo_mode_operations.go`: through the admin-operations
policy, the demo guard answers `/debug/pprof` and every path below it
with `404` for every method, ahead of route authentication, so a write
probe such as the `POST` symbol lookup gets the same `404` instead of
the generic demo `403`. Instances outside demo mode keep the routes, and
`PULSE_PPROF_DISABLED` still removes them everywhere.
- Contract notes in api-contracts (owner), agent-lifecycle and
storage-recovery.

## Proof

- `TestContract_DemoModeHidesPprofFromDemoAdminSession` signs in as the
demo admin through the full router. The same session gets `200` from
`/debug/pprof/cmdline` below the demo guard, and `404` for eight pprof
paths × `GET`/`HEAD`/`OPTIONS`/`POST` through the served handler.
Against the previous policy (`go test -overlay`) `GET` and `HEAD`
returned pprof output (`200`, or the root's `301` into the index),
`OPTIONS` got the router's preflight `200` and `POST` the generic demo
`403`.
- `TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` parses the
router files and fails if a literal route under `/debug/`, with or
without a method prefix, escapes the policy for any of seven methods, so
a new debug handler there cannot ship reachable on a demo by default. A
temporary `GET /debug/vars` registration made it fail as expected.
- Path-handling probe (not committed): 210 raw requests (35 request
targets × six methods), covering trailing and double slashes, dot
segments, `%2F`/`%2e` encodings, absolute-form targets and `CONNECT`,
run against a copy of the router's mux dispatch. None reached a pprof
handler; paths the mux would clean onto pprof only receive a redirect,
and the policy catches the follow-up request.
This commit is contained in:
rcourtman
2026-10-07 22:57:17 +01:00
committed by GitHub
6 changed files with 215 additions and 1 deletions
@@ -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.
@@ -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
@@ -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.
+94
View File
@@ -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": {
+27 -1
View File
@@ -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+"/")
}
}
+61
View File
@@ -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")
}
}