Access Control / Information Disclosure

HIGH
grafana/grafana
Commit: 9b3492fbf162
Affected: Versions prior to 12.4.0 (pre-fix).
2026-07-14 19:58 UTC

Description

The commit gates the display and actions of homepage recommendations based on live plugin state and user permissions. This mitigates information disclosure and unauthorized access to plugin management features by ensuring that recommendations are shown and their actions are available only when the user has appropriate permissions and the relevant plugins are in a state that allows interaction. Prior to this fix, the Home page could surface plugin-related recommendations and actions to users without sufficient permissions, potentially revealing which plugins exist and enabling unintended plugin-related actions.

Proof of Concept

PoC (proof-of-concept) – high-level, actionable steps to reproduce on vulnerable versions prior to 12.4.0: Prerequisites: - Grafana instance version <= 12.3.x (pre-fix). - A user account with at least read access but without PluginsWrite/PluginsInstall permissions. - A set of plugins/apps that could be recommended by the Home page (e.g., grafana-exploretraces-app, grafana-synthetic-monitoring-app). 1) Authenticate as a low-privilege user and load the Grafana Home page. 2) Observe the Recommendations section displaying actions such as "Enable Hosted Traces" or "Add Synthetic Monitoring" regardless of the user lacking plugin management permissions. 3) Attempt to directly trigger a plugin-related action via the recommended link or a direct API call exposed by the UI (without the appropriate permissions being enforced server-side). For example: - GET Grafana Home page to retrieve the recommendations payload and the link targets. - POST to an install/enable endpoint for a plugin that the user should not be allowed to manage, e.g. POST /api/plugins/install with body {"id":"grafana-exploretraces-app"} or POST /api/plugins/grafana-exploretraces-app/install Expected (pre-fix): The backend may accept the request and install/enable the plugin, or the UI allows triggering the action, leading to unauthorized state changes or disclosure of plugin state. Mitigation (post-fix): The UI gates rendering and action handlers based on live plugin state and strict permission checks, e.g. only render the recommendation card when the plugin is not installed and the user has PluginsInstall permission, and disable or hide actions when the user cannot perform plugin management. Notes: The exact API endpoints may vary by Grafana build, but the core idea is to demonstrate that without proper permission checks and gating, a user could discover plugin presence and (in some cases) trigger actions that modify plugin state.

Commit Details

Author: Alex Khomenko

Date: 2026-07-14 19:08 UTC

Message:

Home: Gate homepage recommendations on live plugin state and permissions (#128214) * Home: gate homepage recommendations on live plugin state and permissions * refactor(home): use named recommendation exports * fix(home): harden recommendation gating * comment * fix(home): announce recommendation slides as a carousel region

Triage Assessment

Vulnerability Type: Access Control / Information Disclosure

Confidence: HIGH

Reasoning:

Commit gates the display and actions of homepage recommendations based on live plugin state and user permissions, preventing unauthorized users from seeing or triggering plugin-related actions. This mitigates information disclosure and unauthorized access to plugin management features (access control).

Verification Assessment

Vulnerability Type: Access Control / Information Disclosure

Confidence: HIGH

Affected Versions: Versions prior to 12.4.0 (pre-fix).

Code Diff

diff --git a/public/app/features/home/HomePage.tsx b/public/app/features/home/HomePage.tsx index 9858b84d345db..dd181f0a82d72 100644 --- a/public/app/features/home/HomePage.tsx +++ b/public/app/features/home/HomePage.tsx @@ -17,7 +17,7 @@ import { DashboardTabs } from './DashboardTabs/DashboardTabs'; import { type HomepageTabExtensionProps } from './DashboardTabs/types'; import { HomePageSkeleton } from './HomePageSkeleton'; import { HomeSection } from './HomeSection'; -import Recommendations from './Recommendations/Recommendations'; +import { Recommendations } from './Recommendations/Recommendations'; import useHomeGreeting from './useHomeGreeting'; const getEdition = () => { diff --git a/public/app/features/home/Recommendations/RecommendationCard.tsx b/public/app/features/home/Recommendations/RecommendationCard.tsx index 57d1824bd2689..381b8d7123b32 100644 --- a/public/app/features/home/Recommendations/RecommendationCard.tsx +++ b/public/app/features/home/Recommendations/RecommendationCard.tsx @@ -3,13 +3,15 @@ import { css } from '@emotion/css'; import { type GrafanaTheme2 } from '@grafana/data'; import { Icon, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { recommendationEnableClicked } from '../analytics/main'; + import type { RecommendationItem } from './Recommendations'; -export default function RecommendationCard({ recommendation }: { recommendation: RecommendationItem }) { +export function RecommendationCard({ recommendation }: { recommendation: RecommendationItem }) { const styles = useStyles2(getStyles, recommendation.color); return ( - <Stack direction="column" justifyContent="space-between" gap={2}> + <Stack direction="column" justifyContent="space-between" gap={2} flex={1}> <Stack direction="column" gap={2}> <Text element="h3" variant="h3" color="primary"> {recommendation.title} @@ -33,6 +35,7 @@ export default function RecommendationCard({ recommendation }: { recommendation: icon="arrow-right" iconPlacement="right" href={recommendation.href} + onClick={() => recommendationEnableClicked({ recommendation_id: recommendation.id, source: 'card' })} > {recommendation.action} </LinkButton> diff --git a/public/app/features/home/Recommendations/RecommendationExisting.test.tsx b/public/app/features/home/Recommendations/RecommendationExisting.test.tsx index 49b6975d0fbcc..dfadbdb961a2c 100644 --- a/public/app/features/home/Recommendations/RecommendationExisting.test.tsx +++ b/public/app/features/home/Recommendations/RecommendationExisting.test.tsx @@ -1,6 +1,6 @@ import { render, screen, within } from 'test/test-utils'; -import RecommendationExisting from './RecommendationExisting'; +import { RecommendationExisting } from './RecommendationExisting'; describe('RecommendationExisting', () => { it('opens the dropdown and switches the selected solution', async () => { diff --git a/public/app/features/home/Recommendations/RecommendationExisting.tsx b/public/app/features/home/Recommendations/RecommendationExisting.tsx index db1f7943cd29b..6f053e2cd73cd 100644 --- a/public/app/features/home/Recommendations/RecommendationExisting.tsx +++ b/public/app/features/home/Recommendations/RecommendationExisting.tsx @@ -73,7 +73,7 @@ const existing: ExistingItem[] = [ }, ]; -export default function RecommendationExisting() { +export function RecommendationExisting() { const styles = useStyles2(getStyles); const [selected, setSelected] = useState(existing[0]); diff --git a/public/app/features/home/Recommendations/RecommendationPill.tsx b/public/app/features/home/Recommendations/RecommendationPill.tsx index 512afac9f8f67..978cb980464d3 100644 --- a/public/app/features/home/Recommendations/RecommendationPill.tsx +++ b/public/app/features/home/Recommendations/RecommendationPill.tsx @@ -3,9 +3,11 @@ import { css } from '@emotion/css'; import { type GrafanaTheme2 } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; +import { recommendationEnableClicked } from '../analytics/main'; + import type { RecommendationItem } from './Recommendations'; -export default function RecommendationPill({ recommendation }: { recommendation: RecommendationItem }) { +export function RecommendationPill({ recommendation }: { recommendation: RecommendationItem }) { const styles = useStyles2(getStyles, recommendation.color); return ( @@ -15,6 +17,7 @@ export default function RecommendationPill({ recommendation }: { recommendation: fill="solid" icon={recommendation.icon} href={recommendation.href} + onClick={() => recommendationEnableClicked({ recommendation_id: recommendation.id, source: 'pill' })} className={styles.pill} > {recommendation.action} diff --git a/public/app/features/home/Recommendations/Recommendations.test.tsx b/public/app/features/home/Recommendations/Recommendations.test.tsx index a013c6b3d44b9..6429c44487ba7 100644 --- a/public/app/features/home/Recommendations/Recommendations.test.tsx +++ b/public/app/features/home/Recommendations/Recommendations.test.tsx @@ -1,16 +1,202 @@ -import { act, render, screen, userEvent } from 'test/test-utils'; +import { act, render, screen, userEvent, waitFor } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge'; +import { type LocalPlugin } from 'app/features/plugins/admin/types'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { Recommendations } from './Recommendations'; + +const mockGet = jest.fn(); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => ({ get: mockGet }), +})); + +jest.mock('app/features/alerting/unified/hooks/usePluginBridge', () => ({ + ...jest.requireActual('app/features/alerting/unified/hooks/usePluginBridge'), + usePluginBridge: jest.fn(), +})); + +// The RecommendationExisting child fetches its overview from Prometheus; resolve to an empty +// cluster so tests exercise the (deterministic) stub entries instead of hitting a datasource. +jest.mock('./kubernetesData', () => ({ + ...jest.requireActual('./kubernetesData'), + fetchKubernetesOverview: jest.fn().mockResolvedValue({ + clusters: 0, + pods: 0, + alertsFiring: null, + unhealthyPods: null, + restarts1h: null, + notReadyNodes: null, + }), + fetchClusterCpuSeries: jest.fn().mockResolvedValue(null), +})); + +const APP_IDS = [ + 'grafana-exploretraces-app', + 'grafana-synthetic-monitoring-app', + 'grafana-app-observability-app', + 'grafana-kowalski-app', +]; +const listItem = (id: string, overrides: Partial<LocalPlugin> = {}) => ({ + id, + enabled: false, + accessControl: { [AccessControlAction.PluginsWrite]: true }, + ...overrides, +}); + +const mockUsePluginBridge = jest.mocked(usePluginBridge); -import Recommendations from './Recommendations'; +beforeEach(() => { + window.localStorage.clear(); + mockUsePluginBridge.mockReset(); + mockUsePluginBridge.mockReturnValue({ loading: false, installed: true }); + mockGet.mockReset(); + mockGet.mockResolvedValue(APP_IDS.map((id) => listItem(id))); + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); +}); + +afterEach(() => jest.restoreAllMocks()); describe('Recommendations', () => { - beforeEach(() => { - window.localStorage.clear(); + it('renders nothing while plugin data is loading', async () => { + mockUsePluginBridge.mockReturnValue({ loading: true }); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('renders nothing when Kubernetes Monitoring is not installed', async () => { + mockUsePluginBridge.mockReturnValue({ loading: false, installed: false }); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('renders nothing when the user cannot manage plugins', async () => { + jest.mocked(contextSrv.hasPermission).mockReturnValue(false); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + expect(mockGet).not.toHaveBeenCalled(); + expect(mockUsePluginBridge).not.toHaveBeenCalled(); + }); + + it('drops recommendations whose app is already enabled', async () => { + mockGet.mockResolvedValue( + APP_IDS.map((id) => + listItem(id, { + enabled: id === 'grafana-exploretraces-app' || id === 'grafana-synthetic-monitoring-app', + }) + ) + ); + + render(<Recommendations />); + + expect(await screen.findByRole('link', { name: /Enable Application Observability/ })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Enable Hosted Traces/ })).not.toBeInTheDocument(); + }); + + it('shows installed-but-disabled cards but hides not-installed cards for a write-only user', async () => { + jest.mocked(contextSrv.hasPermission).mockImplementation((action) => action === AccessControlAction.PluginsWrite); + mockGet.mockResolvedValue(APP_IDS.filter((id) => id !== 'grafana-exploretraces-app').map((id) => listItem(id))); + + render(<Recommendations />); + + await screen.findByText('Recommendations for your stack'); + // Cards past the active one are aria-hidden in the carousel, so query with { hidden: true }. + expect(screen.queryByRole('link', { name: /Add Synthetic Monitoring/, hidden: true })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Enable Hosted Traces/, hidden: true })).not.toBeInTheDocument(); + }); + + it('shows not-installed cards but hides installed-but-disabled cards for an install-only user', async () => { + jest.mocked(contextSrv.hasPermission).mockImplementation((action) => action === AccessControlAction.PluginsInstall); + mockGet.mockResolvedValue( + APP_IDS.filter((id) => id !== 'grafana-exploretraces-app').map((id) => listItem(id, { accessControl: {} })) + ); + + render(<Recommendations />); + + await screen.findByText('Recommendations for your stack'); + expect(screen.queryByRole('link', { name: /Enable Hosted Traces/, hidden: true })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Add Synthetic Monitoring/, hidden: true })).not.toBeInTheDocument(); + }); + + it('hides the section when the plugin list cannot be fetched', async () => { + mockGet.mockRejectedValue(new Error('boom')); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('renders nothing when the plugin list is empty despite Kubernetes Monitoring being installed', async () => { + mockGet.mockResolvedValue([]); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('renders nothing for legacy Admin roles without plugin permissions', async () => { + jest.mocked(contextSrv.hasPermission).mockReturnValue(false); + jest.spyOn(contextSrv, 'hasRole').mockImplementation((role) => role === 'Admin'); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('only shows disabled cards the user can write', async () => { + jest.mocked(contextSrv.hasPermission).mockImplementation((action) => action === AccessControlAction.PluginsWrite); + mockGet.mockResolvedValue( + APP_IDS.map((id) => + listItem(id, { + accessControl: id === 'grafana-exploretraces-app' ? { [AccessControlAction.PluginsWrite]: true } : {}, + }) + ) + ); + + render(<Recommendations />); + + expect(await screen.findByRole('link', { name: /Enable Hosted Traces/ })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Add Synthetic Monitoring/ })).not.toBeInTheDocument(); + }); + + it('renders nothing when every recommended app is enabled', async () => { + mockGet.mockResolvedValue(APP_IDS.map((id) => listItem(id, { enabled: true }))); + + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('hides install cards when plugin admin is disabled', async () => { + config.pluginAdminEnabled = false; + jest.mocked(contextSrv.hasPermission).mockImplementation((action) => action === AccessControlAction.PluginsInstall); + mockGet.mockResolvedValue( + APP_IDS.filter((id) => id !== 'grafana-exploretraces-app').map((id) => listItem(id, { accessControl: {} })) + ); + + try { + const { container } = render(<Recommendations />); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + } finally { + config.pluginAdminEnabled = true; + } }); it('collapses and expands the recommendations card', async () => { const { user } = render(<Recommendations />); - expect(screen.getByText('Recommendations for your stack')).toBeInTheDocument(); + expect(await screen.findByText('Recommendations for your stack')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Hide' })); @@ -29,11 +215,11 @@ describe('Recommendations', () => { expect(screen.getByRole('button', { name: 'Previous' })).toBeInTheDocument(); }); - it('loads the collapsed state from local storage', () => { + it('loads the collapsed state from local storage', async () => { window.localStorage.setItem('grafana.home.recommendations.collapsed', 'true'); render(<Recommendations />); - expect(screen.getByRole('button', { name: 'Show' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Show' })).toBeInTheDocument(); expect(screen.getByText('Recommendations for your stack')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Show' })).toHaveAttribute('aria-expanded', 'false'); }); @@ -46,6 +232,9 @@ describe('Recommendations', () => { const getVisibleTitle = () => getVisibleHeading()?.textContent?.trim() ?? ''; const getVisibleSlide = () => getVisibleHeading()?.closest('div[aria-hidden="false"]'); + // The enabled lookup resolves async; wait for the carousel before reading slides. + await screen.findByRole('button', { name: 'Next' }); + const initialVisibleSlide = getVisibleSlide(); const initialVisibleTitle = getVisibleTitle(); @@ -69,8 +258,8 @@ describe('Recommendations', () => { it('navigates recommendations with dots', async () => { const { user } = render(<Recommendations />); + expect(await screen.findByRole(' ... [truncated]
← Back to Alerts View on GitHub →