diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 4cdbbd806..1afa7bc97 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -1498,6 +1498,22 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt return } + // Check proxy auth admin status if applicable + if h.getConfig(r.Context()).ProxyAuthSecret != "" { + if valid, username, isAdmin := CheckProxyAuth(h.getConfig(r.Context()), r); valid && !isAdmin { + log.Warn(). + Str("ip", r.RemoteAddr). + Str("path", r.URL.Path). + Str("method", r.Method). + Str("username", username). + Msg("Non-admin user attempted AI connection test") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"}) + return + } + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -1537,6 +1553,22 @@ func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Re return } + // Check proxy auth admin status if applicable + if h.getConfig(r.Context()).ProxyAuthSecret != "" { + if valid, username, isAdmin := CheckProxyAuth(h.getConfig(r.Context()), r); valid && !isAdmin { + log.Warn(). + Str("ip", r.RemoteAddr). + Str("path", r.URL.Path). + Str("method", r.Method). + Str("username", username). + Msg("Non-admin user attempted AI provider test") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"}) + return + } + } + // Get provider from URL path (e.g., /api/ai/test/anthropic -> anthropic) provider := strings.TrimPrefix(r.URL.Path, "/api/ai/test/") if provider == "" || provider == r.URL.Path { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 7b8e20643..94af76f45 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -2028,6 +2028,7 @@ func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) { cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router.aiSettingsHandler.legacyConfig = cfg cases := []struct { method string @@ -2082,6 +2083,8 @@ func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) { {method: http.MethodPost, path: "/api/ai/oauth/start", body: `{}`}, {method: http.MethodPost, path: "/api/ai/oauth/exchange", body: `{}`}, {method: http.MethodPost, path: "/api/ai/oauth/disconnect", body: `{}`}, + {method: http.MethodPost, path: "/api/ai/test", body: `{}`}, + {method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`}, {method: http.MethodPost, path: "/api/agents/docker/containers/update", body: `{}`}, {method: http.MethodDelete, path: "/api/agents/docker/hosts/host-1", body: ``}, {method: http.MethodDelete, path: "/api/agents/kubernetes/clusters/cluster-1", body: ``}, @@ -2399,6 +2402,46 @@ func TestAISettingsUpdateRejectsProxyNonAdmin(t *testing.T) { } } +func TestAITestConnectionRejectsProxyNonAdmin(t *testing.T) { + cfg := newTestConfigWithTokens(t) + cfg.ProxyAuthSecret = "proxy-secret" + cfg.ProxyAuthUserHeader = "X-Remote-User" + cfg.ProxyAuthRoleHeader = "X-Remote-Roles" + cfg.ProxyAuthAdminRole = "admin" + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router.aiSettingsHandler.legacyConfig = cfg + + req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`)) + req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) + req.Header.Set("X-Remote-User", "viewer-user") + req.Header.Set("X-Remote-Roles", "viewer") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin proxy AI test, got %d", rec.Code) + } +} + +func TestAITestProviderRejectsProxyNonAdmin(t *testing.T) { + cfg := newTestConfigWithTokens(t) + cfg.ProxyAuthSecret = "proxy-secret" + cfg.ProxyAuthUserHeader = "X-Remote-User" + cfg.ProxyAuthRoleHeader = "X-Remote-Roles" + cfg.ProxyAuthAdminRole = "admin" + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + router.aiSettingsHandler.legacyConfig = cfg + + req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`)) + req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) + req.Header.Set("X-Remote-User", "viewer-user") + req.Header.Set("X-Remote-Roles", "viewer") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin proxy AI provider test, got %d", rec.Code) + } +} + func TestAIChatEndpointsRequireAIChatScope(t *testing.T) { rawToken := "ai-chat-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)