From dca7b19bbd03ce8c74a5694bd75ddebe2f8939e8 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Mon, 20 Jul 2026 13:56:31 -0600 Subject: [PATCH] Enforce API-only endpoint restrictions on /debug/* routes A global-admin API-only token scoped to an api_endpoints allowlist could still reach every /debug/* route, because the debug handler's middleware only checked for the global-admin role and never consulted the token's endpoint restrictions like the main API path does. Debug routes are not in the public API catalog, so they can never appear in an allowlist. Deny restricted API-only tokens (api_only with a non-empty api_endpoints list) at the debug middleware, matching the least-privilege scoping APIOnlyEndpointCheck enforces elsewhere. --- changes/16879-debug-api-only-restriction | 1 + server/service/debug_handler.go | 9 +++++ server/service/debug_handler_test.go | 45 ++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 changes/16879-debug-api-only-restriction diff --git a/changes/16879-debug-api-only-restriction b/changes/16879-debug-api-only-restriction new file mode 100644 index 00000000000..6029052a4c8 --- /dev/null +++ b/changes/16879-debug-api-only-restriction @@ -0,0 +1 @@ +- Enforced API-only endpoint restrictions on the debug routes so a restricted API-only token can no longer reach `/debug/*`. diff --git a/server/service/debug_handler.go b/server/service/debug_handler.go index 9a1f215c35f..afd70a774dd 100644 --- a/server/service/debug_handler.go +++ b/server/service/debug_handler.go @@ -44,6 +44,15 @@ func (m *debugAuthenticationMiddleware) Middleware(next http.Handler) http.Handl return } + // Debug routes are not part of the public API catalog, so they can never appear in an + // API-only user's endpoint allowlist. A restricted API-only token (api_only with a + // non-empty api_endpoints list) must therefore be denied here, matching the least-privilege + // scoping that APIOnlyEndpointCheck enforces on the main API path. + if v.User.APIOnly && len(v.User.APIEndpoints) > 0 { + http.Error(w, "Unauthorized", http.StatusForbidden) + return + } + // Attach the authenticated viewer to the request context so downstream debug handlers can record who triggered an // action (e.g. updating trace sampler settings). next.ServeHTTP(w, r.WithContext(viewer.NewContext(r.Context(), *v))) diff --git a/server/service/debug_handler_test.go b/server/service/debug_handler_test.go index 57555c297fc..4419a6929cc 100644 --- a/server/service/debug_handler_test.go +++ b/server/service/debug_handler_test.go @@ -99,7 +99,9 @@ func TestDebugHandlerAuthenticationFailsDueToRole(t *testing.T) { } } -func TestDebugHandlerAuthenticationSucceeds(t *testing.T) { +func TestDebugHandlerAuthenticationFailsForRestrictedAPIOnlyUser(t *testing.T) { + // A global-admin API-only token scoped to an endpoint allowlist must not reach the debug + // routes: those routes are not in the public API catalog, so they can never be allowlisted. svc := &mockService{} svc.On( "GetSessionByKey", @@ -110,7 +112,11 @@ func TestDebugHandlerAuthenticationSucceeds(t *testing.T) { "UserUnauthorized", mock.Anything, uint(42), - ).Return(&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, nil) + ).Return(&fleet.User{ + GlobalRole: ptr.String(fleet.RoleAdmin), + APIOnly: true, + APIEndpoints: []fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/hosts"}}, + }, nil) handler := MakeDebugHandler(svc, testConfig, nil, nil, nil) @@ -119,5 +125,38 @@ func TestDebugHandlerAuthenticationSucceeds(t *testing.T) { res := httptest.NewRecorder() handler.ServeHTTP(res, req) - assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, http.StatusForbidden, res.Code) +} + +func TestDebugHandlerAuthenticationSucceeds(t *testing.T) { + // An unrestricted API-only admin (empty APIEndpoints) retains full access, matching the main + // API path where APIOnlyEndpointCheck is a no-op for tokens with no endpoint restrictions. + for test, user := range map[string]fleet.User{ + "admin session": {GlobalRole: ptr.String(fleet.RoleAdmin)}, + "unrestricted api-only": {GlobalRole: ptr.String(fleet.RoleAdmin), APIOnly: true}, + "api-only empty allowlist": {GlobalRole: ptr.String(fleet.RoleAdmin), APIOnly: true, APIEndpoints: []fleet.APIEndpointRef{}}, + } { + t.Run(test, func(t *testing.T) { + svc := &mockService{} + svc.On( + "GetSessionByKey", + mock.Anything, + "fake_session_key", + ).Return(&fleet.Session{UserID: 42, ID: 1}, nil) + svc.On( + "UserUnauthorized", + mock.Anything, + uint(42), + ).Return(&user, nil) + + handler := MakeDebugHandler(svc, testConfig, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "https://fleetdm.com/debug/pprof/cmdline", nil) + req.Header.Add("Authorization", "BEARER fake_session_key") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + assert.Equal(t, http.StatusOK, res.Code) + }) + } }