mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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+"/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user