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) + }) + } }