Information disclosure / Access control

MEDIUM
grafana/grafana
Commit: 8d41e29d6146
Affected: <= 12.4.0 (Grafana 12.x line, tracked version 12.4.0)
2026-04-03 22:30 UTC

Description

The commit adds UI-level RBAC checks to hide or disable access to contact point information and certain plugin pages unless the user has permission. It introduces a canAccessPluginPage helper and uses it to conditionally render links or show tooltips, mitigating information disclosure via the UI (e.g., contact point search links) and gating access to plugin-related pages in the Alerts UI. This is a security-related improvement focused on the presentation layer and does not appear to enforce backend access control beyond the UI. It is a genuine fix for information disclosure via the UI, with medium confidence given potential gaps in backend enforcement.

Proof of Concept

Proof-of-concept (exploit path): Background: In Grafana 12.x prior to this patch, alerting UI could leak contact point details by rendering a Delivered to link that navigates to a contact point search, and certain plugin pages could be accessed or presented in the UI without proper per-page RBAC checks. The patch adds UI guards to hide/disable those links and introduces canAccessPluginPage to gate per-plugin page access based on action/role hints in plugin includes. Goal: Demonstrate information disclosure and improper access to plugin pages via the UI for a user without the necessary permissions. Prerequisites: - Grafana 12.x instance with Incident plugin (or similar plugin with includes that gate pages). - A user account with limited UI permissions (e.g., OrgRole Viewer or a user lacking AlertingNotificationsRead / AlertingReceiversRead or specific plugin actions). - An alert group with a receiver/contact point that would previously render a link to view the contact point. Steps to reproduce (pre-fix behavior, i.e., without this patch): 1) Log in as a restricted user (no permission to view contact points). 2) Open an alert group detail view that shows a section like: Delivered to John Doe (previous UI would render 'John Doe' as a clickable link to a contact point search). 3) Click the Delivered-to contact point link or inspect the element; you should be able to navigate to a contact point search and view details about the contact point, causing information disclosure. Steps to reproduce (post-fix behavior, with this patch): 1) Log in as the same restricted user. 2) Open the same alert group detail view. 3) Observe that the Delivered to line now renders without a usable link or shows a disabled state with a tooltip such as: "You do not have permission to view contact points". The contact point link is not navigable and the actual contact point details are not exposed in the UI. Optional automated PoC (Playwright-based): // POC script sketch (fill in exact selectors/paths for your environment) const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('http://localhost:3000/login'); await page.fill('#login', process.env.GRAFANA_USER); await page.fill('#password', process.env.GRAFANA_PASSWORD); await page.click('button[type="submit"]'); await page.waitForNavigation(); // Navigate to an alerting group detail page that contains a Delivered-to contact point await page.goto('http://localhost:3000/grafana/alerting/group/EXAMPLE'); // Pre-fix-ish expectation: presence of a link to view the contact point const linkVisible = await page.$('a[href*="contact-point-search"]') !== null; console.log('Contact point link visible (should be false after patch):', linkVisible); // If the UI renders a non-link with a tooltip when lacking permission, you might instead assert disabled state // Example: // const isDisabled = await page.$('text=Delivered to JOHN_DOE') && await page.$('span[aria-disabled="true"]'); // console.log('Contact point is disabled with tooltip (expected after patch):', !!isDisabled); await browser.close(); })(); Notes: - The exact selectors and URL paths depend on your Grafana deployment and the alerting plugin configuration. Replace EXAMPLE with a real alert group ID and adjust selectors accordingly. - The key proof is that, after this patch, restricted users should no longer have an accessible link to view the contact point; either the link is removed or rendered with a disabled state and a tooltip explaining the permission requirement.

Commit Details

Author: Lauren

Date: 2026-04-01 11:32 UTC

Message:

Alerting: Improve alerts activity UI RBAC (#121458)

Triage Assessment

Vulnerability Type: Information disclosure / Access control

Confidence: MEDIUM

Reasoning:

The commit adds UI-level RBAC checks to hide or disable access to contact point information and plugin pages unless the user has permission. This reduces information disclosure and unauthorized access by not exposing contact point details and by gating access to certain plugin pages. It introduces a canAccessPluginPage helper and uses it to conditionally render links or show tooltips, indicating an access control/security-related improvement.

Verification Assessment

Vulnerability Type: Information disclosure / Access control

Confidence: MEDIUM

Affected Versions: <= 12.4.0 (Grafana 12.x line, tracked version 12.4.0)

Code Diff

diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx index 5c5164d1398b8..b83d403d87895 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx @@ -3,10 +3,11 @@ import { useState } from 'react'; import { AlertLabels } from '@grafana/alerting/unstable'; import { type GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { Stack, TextLink, useStyles2 } from '@grafana/ui'; +import { Trans, t } from '@grafana/i18n'; +import { Stack, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; import { type AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import { useCanViewContactPoints } from '../../hooks/useAbilities'; import { createContactPointSearchLink } from '../../utils/misc'; import { CollapseToggle } from '../CollapseToggle'; import { MetaText } from '../MetaText'; @@ -22,6 +23,7 @@ interface Props { export const AlertGroup = ({ alertManagerSourceName, group }: Props) => { const [isCollapsed, setIsCollapsed] = useState<boolean>(true); const styles = useStyles2(getStyles); + const canViewContactPoint = useCanViewContactPoints(); // When group is grouped, receiver.name is 'NONE' as it can contain multiple receivers const receiverInGroup = group.receiver.name !== 'NONE'; @@ -43,17 +45,32 @@ export const AlertGroup = ({ alertManagerSourceName, group }: Props) => { {receiverInGroup && ( <MetaText icon="at"> - <Trans i18nKey="alerting.alert-group.delivered-to" values={{ name: group.receiver.name }}> - Delivered to{' '} - <TextLink - href={createContactPointSearchLink(contactPoint, alertManagerSourceName)} - variant="bodySmall" - color="primary" - inline={false} + {canViewContactPoint ? ( + <Trans i18nKey="alerting.alert-group.delivered-to" values={{ name: group.receiver.name }}> + Delivered to{' '} + <TextLink + href={createContactPointSearchLink(contactPoint, alertManagerSourceName)} + variant="bodySmall" + color="primary" + inline={false} + > + {'{{name}}'} + </TextLink> + </Trans> + ) : ( + <Tooltip + content={t( + 'alerting.alert-group.view-contact-point-no-permission', + 'You do not have permission to view contact points' + )} > - {'{{name}}'} - </TextLink> - </Trans> + <span> + {t('alerting.alert-group.delivered-to-disabled', 'Delivered to {{name}}', { + name: contactPoint, + })} + </span> + </Tooltip> + )} </MetaText> )} </Stack> @@ -92,4 +109,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'row', alignItems: 'center', }), + disabledContactPointName: css({ + opacity: 0.7, + cursor: 'not-allowed', + }), }); diff --git a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx index c469aea8c09aa..789ef18d71168 100644 --- a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx +++ b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx @@ -1,7 +1,7 @@ import { t } from '@grafana/i18n'; import { Menu, Tooltip } from '@grafana/ui'; -import { useIrmPlugin } from '../../hooks/usePluginBridge'; +import { canAccessPluginPage, useIrmPlugin } from '../../hooks/usePluginBridge'; import { SupportedPlugin } from '../../types/pluginBridges'; import { createBridgeURL } from '../PluginBridge'; @@ -13,12 +13,14 @@ interface Props { export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => { const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident); + const incidentPath = '/incidents/declare'; - const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', { + const bridgeURL = createBridgeURL(pluginId, incidentPath, { title, severity, url, }); + const hasAccess = settings ? canAccessPluginPage(settings, createBridgeURL(pluginId, incidentPath)) : false; return ( <> @@ -43,7 +45,21 @@ export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: /> </Tooltip> )} - {settings && ( + {settings && !hasAccess && ( + <Tooltip + content={t( + 'alerting.declare-incident-menu-item.content-you-do-not-have-permission-to-access-incident', + 'You do not have permission to access Incident' + )} + > + <Menu.Item + label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')} + icon="fire" + disabled + /> + </Tooltip> + )} + {settings && hasAccess && ( <Menu.Item label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')} url={bridgeURL} diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 885c85c063108..716ad36acd939 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -197,6 +197,26 @@ export const useAlertingAbility = (action: AlertingAction): Ability => { return allAbilities[action]; }; +/** + * UI-only permission helper for places that need contact point visibility checks + * without requiring AlertmanagerContext. + */ +export function useCanViewContactPoints(): boolean { + return useMemo( + () => + ctx.hasPermission(AccessControlAction.AlertingNotificationsRead) || + ctx.hasPermission(AccessControlAction.AlertingReceiversRead), + [] + ); +} + +/** + * UI-only permission helper for actions that create silences. + */ +export function useCanCreateSilences(): boolean { + return useMemo(() => ctx.hasPermission(AccessControlAction.AlertingInstanceCreate), []); +} + /** * This one will check for enrichment abilities */ diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx index ef180c8e26339..c4c6c0a38607d 100644 --- a/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx +++ b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx @@ -1,10 +1,13 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { OrgRole, PluginIncludeType } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; + import { useGetPluginSettingsQuery } from '../api/pluginsApi'; import { pluginMeta } from '../testSetup/plugins'; import { SupportedPlugin } from '../types/pluginBridges'; -import { useIrmPlugin } from './usePluginBridge'; +import { canAccessPluginPage, useIrmPlugin } from './usePluginBridge'; jest.mock('../api/pluginsApi'); @@ -238,3 +241,69 @@ describe('useIrmPlugin', () => { expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]); }); }); + +describe('canAccessPluginPage', () => { + const previousRole = contextSrv.user.orgRole; + const previousPermissions = contextSrv.user.permissions; + const previousIsEditor = contextSrv.isEditor; + const previousIsGrafanaAdmin = contextSrv.isGrafanaAdmin; + + afterEach(() => { + contextSrv.user.orgRole = previousRole; + contextSrv.user.permissions = previousPermissions; + contextSrv.isEditor = previousIsEditor; + contextSrv.isGrafanaAdmin = previousIsGrafanaAdmin; + }); + + it('returns false when include requires action and user lacks permission', () => { + contextSrv.user.permissions = {}; + const settings = { + ...pluginMeta[SupportedPlugin.Incident], + includes: [ + { + type: PluginIncludeType.page, + name: 'Declare incident', + path: '/a/grafana-incident-app/incidents/declare', + action: 'grafana-incident-app.incidents:write', + }, + ], + }; + + expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(false); + }); + + it('returns true when include requires action and user has permission', () => { + contextSrv.user.permissions = { 'grafana-incident-app.incidents:write': true }; + const settings = { + ...pluginMeta[SupportedPlugin.Incident], + includes: [ + { + type: PluginIncludeType.page, + name: 'Declare incident', + path: '/a/grafana-incident-app/incidents/declare', + action: 'grafana-incident-app.incidents:write', + }, + ], + }; + + expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(true); + }); + + it('returns false when include role is editor and user is viewer', () => { + contextSrv.user.orgRole = OrgRole.Viewer; + contextSrv.isEditor = false; + const settings = { + ...pluginMeta[SupportedPlugin.Incident], + includes: [ + { + type: PluginIncludeType.page, + name: 'Declare incident', + path: '/a/grafana-incident-app/incidents/declare', + role: OrgRole.Editor, + }, + ], + }; + + expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.ts b/public/app/features/alerting/unified/hooks/usePluginBridge.ts index 8e96c5743784e..1f5f9afed47b4 100644 --- a/public/app/features/alerting/unified/hooks/usePluginBridge.ts +++ b/public/app/features/alerting/unified/hooks/usePluginBridge.ts @@ -1,5 +1,6 @@ -import { type PluginMeta } from '@grafana/data'; +import { OrgRole, type PluginMeta } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; import { useGetPluginSettingsQuery } from '../api/pluginsApi'; import { type PluginID } from '../components/PluginBridge'; @@ -45,6 +46,34 @@ export interface PluginBridgeResult { error?: Error; settings?: PluginMeta<{}>; } + +/** + * Checks access to a specific plugin page path using the same include role/action + * semantics as the core app plugin route guard. + */ +export function canAccessPluginPage(settings: PluginMeta<{}>, pluginPagePath: string): boolean { + const requestedPath = pluginPagePath.split('?')[0]; + const pluginInclude = settings.includes?.find((include) => include.path === requestedPath); + + if (!pluginInclude) { + return true; + } + + if (pluginInclude.action) { + return contextSrv.hasPermission(pluginInclude.action); + } + + if (contextSrv.isGrafanaAdmin || contextSrv.user.orgRole === OrgRole.Admin) { + return true; + } + + const includeRole = pluginInclude.role ?? ''; + if (!includeRole || (contextSrv.isEditor && includeRole === OrgRole.Viewer)) { + return true; + } + + return contextSrv.hasRole(includeRole); +} /** * Hook that checks for IRM plugin first, falls back to specified plugin. * IRM replaced both OnCall and Incident - this provides backward compatibility. diff --git a/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx b/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx index f392bd4352a8a..863616e96ae1e 100644 --- a/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx +++ b/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx @@ -2,11 +2,12 @@ import { type CreateNotificationqueryNotificationEntry } from '@grafana/api-clie import { useAssistant } from '@grafana/assistant'; import { AppEvents } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Dropdown, Menu } from '@grafana/ui'; +import { Dropdown, Menu, Tooltip } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; import MoreButton from '../components/MoreButton'; import { DeclareIncidentMenuItem } from '../components/bridges/DeclareIncidentButton'; +import { useCanCreateSilences, useCanViewContactPoints } from '../hooks/useAbilities'; import { isLocalDevEnv, isOpenSourceEdition, makeLabelBasedSilenceLink } from '../utils/misc'; import { createRelativeUrl } from '../utils/url'; @@ -18,6 +19,8 @@ interface NotificationActionsMenuProps { export function NotificationActionsMenu({ notification }: NotificationActionsMenuProps) { const { isAvailable: isAssistantAvailable, openAssistant } = useAssistant(); + const canViewContactPoint = useCanViewContactPoints(); + const canSilence = useCanCreateSilences(); const shouldShowDeclareIncident = !isOpenSourceEdition() || isLocalDevEnv(); @@ -27,11 +30,26 @@ export function NotificationActionsMenu({ notification }: NotificationActionsMen const menuItems = ( <> - <Menu.Item - label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')} - icon="at" - url={createRelativeUrl(`/alerting/notifications?search=${encodeURIComponent(notification.receiver)}`)} - /> + {canViewContactPoint ? ( + <Menu.Item + label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')} + icon="at" + url={createRelativeUrl(`/alerting/notifications?search=${encodeURIComponent(notification.receiver)}`)} + /> + ) : ( + <Tooltip + content={t( + 'alerting.notification-detail.menu-view-contact-point-no-permission', + 'You do not have permission to view contact points' + )} + > + <Menu.Item + label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')} + icon="at" + disabled + /> + </Tooltip> + )} {ruleUIDs.length === 1 && ( <Menu.Item label={t('alerting.notification-detail.menu-view-rule', 'View alert rule')} @@ -51,13 +69,27 @@ export function NotificationActionsMenu({ notification }: NotificationActionsMen /> ))} <Menu.Divider /> - ... [truncated]
← Back to Alerts View on GitHub →