Information Disclosure / Authorization Bypass (RBAC) in UI visibility

MEDIUM
grafana/grafana
Commit: d9eadb70b3e9
Affected: Versions prior to 12.4.0 (before this commit)
2026-04-08 12:11 UTC

Description

The commit fixes a UI-driven information-disclosure risk in the Grafana Connections landing page. Previously, the landing page derived its cards from a hardcoded CardData.ts, which did not reflect actual backend visibility (RBAC) or plugin availability. This could cause a user to see cards for resources they should not access, effectively leaking the existence (and structure) of restricted plugins or data sources. The patch derives landing page cards from the backend-provided navIndex (which applies full plugin availability and RBAC checks) and enriches the card data via a metadata map. This aligns UI visibility with backend access control and edition-specific copy, reducing information disclosure and preventing exposure of inaccessible items.

Proof of Concept

Proof of Concept (pre-fix demonstration): 1) Setup Grafana version prior to 12.4.0 with a user who has limited RBAC permissions (e.g., cannot access the Private Data Source Connect page or the Grafana Collector plugin). 2) In a test harness, mock the navigation index (navIndex) for the Connections section to include both allowed and disallowed items, for example: - allowed: { url: '/connections/add-new-connection', text: 'Add new connection' } - disallowed: { url: '/connections/private-data-source-connections', text: 'Private data source connect' } 3) Render the Connections landing page (likely via the ConnectionsHomePage component) using the restricted RBAC context. 4) Observe that the UI renders a card labeled 'Private data source connect' (and possibly other restricted cards) even though the user should not have access to them. Attempting to navigate to /connections/private-data-source-connections may be blocked by backend RBAC, but the UI has already exposed the existence of that resource via the card. 5) This demonstrates an information-disclosure/observation channel via the landing page UI, not direct unauthorized action. With the fix (this commit): the landing page cards are derived from the backend navIndex with RBAC checks applied, so restricted items do not appear to the user, and only items the backend allows are shown. A corresponding test path in the repo also updates to reflect cards coming from the nav tree and metadata overrides, ensuring only permitted items render as cards.

Commit Details

Author: Jára Benc

Date: 2026-04-08 11:14 UTC

Message:

feat(connections): derive landing page cards from nav tree (#122017) * feat(connections): derive landing page cards from nav tree The Connections landing page previously used a hardcoded card list (CardData.ts) that was not aware of which plugins were actually enabled or whether the current user had RBAC access to them. Cards are now derived directly from the navIndex 'connections' children, which are already built by the backend with full plugin availability and RBAC checks applied. A URL-keyed CardMetadata map provides icons and descriptions for well-known items, taking priority over nav tree values (which are written for nav tooltip context, not landing page cards). This means: - Cards appear only when the backend decides the user can see them - The flat nav mode from grafana-collector-app PR #1047 is automatically reflected: Alloy Configuration / Collector Setup, Instrumentation Hub, Fleet Management appear as individual cards when collectorFlattenedNav is enabled, without any changes needed here - Any future plugin registering pages under Connections gets a card for free Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connections): restore OSS/Cloud subtitle distinction The subtitle on the Connections landing page was OSS/Cloud conditional in the original code. The nav-tree refactor accidentally always showed the Cloud copy. Restored the isOnPrem check so OSS instances continue to see their original subtitle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connections): restore OSS card description for Add new connection The original CardData.ts had OSS-specific text for the Add new connection card ('Connect to a new data source' vs 'Connect data to Grafana through data sources, integrations and apps' for Cloud). CardMetadata now accepts isOnPrem and returns the correct subtitle per edition. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connections): restore OSS title and description parity with original CardData CardMetadata now also supports an optional text override so the Data sources card shows 'View configured data sources' on OSS (matching the original getOssCardData behaviour). Config is reset to Cloud defaults in beforeEach to prevent test state bleed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connections): address Copilot review comments and sync locale file - Use nullish coalescing (??) instead of || for subTitle/text merging to avoid overwriting intentionally empty strings - cardMetadata is computed inside the component (already the case after isOnPrem was introduced as a parameter) so translations resolve at render time, not at import time - Sync mock: standalone navIndex entry for infrastructure now says 'Integrations' to match the children entry - Update CardMetadata comment to accurately describe that core nav items are also intentionally overridden for landing-page card copy - Sync en-US locale file: remove deleted title keys, add new collector-setup, fleet-management and instrumentation-hub subtitle keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(i18n): remove unused OSS data-sources subtitle key from locale file The OSS data-sources card now uses the cloud subtitle key directly; the oss-specific subtitle is no longer referenced in code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Information Disclosure / Authorization Ballback (RBAC) and access control

Confidence: MEDIUM

Reasoning:

The commit changes the Connections landing page to derive cards from the navigation tree which is already built by the backend with plugin availability and RBAC checks applied. This prevents showing cards for items the user should not access, reducing potential information disclosure and inadvertent access. It also ties visibility to backend decisions rather than hardcoded lists, addressing prior exposure risk.

Verification Assessment

Vulnerability Type: Information Disclosure / Authorization Bypass (RBAC) in UI visibility

Confidence: MEDIUM

Affected Versions: Versions prior to 12.4.0 (before this commit)

Code Diff

diff --git a/public/app/features/connections/Connections.test.tsx b/public/app/features/connections/Connections.test.tsx index 1387813ab6894..dd53d89f39e37 100644 --- a/public/app/features/connections/Connections.test.tsx +++ b/public/app/features/connections/Connections.test.tsx @@ -40,22 +40,28 @@ describe('Connections', () => { const mockDatasources = getMockDataSources(3); beforeEach(() => { + config.pluginAdminExternalManageEnabled = true; (api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources); (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); }); - test('shows the "Connections Homepage" page by default when edition is Cloud', async () => { + test('shows cloud subtitle and cards from nav tree when edition is Cloud', async () => { config.pluginAdminExternalManageEnabled = true; renderPage(); - // Add new connection card + // Cards are derived from the nav tree (navIndex mock) expect(await screen.findByText('Add new connection')).toBeVisible(); expect(await screen.findByText('Collector')).toBeVisible(); expect(await screen.findByText('Data sources')).toBeVisible(); expect(await screen.findByText('Integrations')).toBeVisible(); expect(await screen.findByText('Private data source connect')).toBeVisible(); - // Heading + // Metadata enrichment: descriptions come from CardMetadata + expect( + await screen.findByText('Connect data to Grafana through data sources, integrations and apps') + ).toBeVisible(); + + // Cloud subtitle expect(await screen.findByText('Welcome to Connections')).toBeVisible(); expect( await screen.findByText( @@ -64,21 +70,49 @@ describe('Connections', () => { ).toBeVisible(); }); - test('shows the OSS "Connections Homepage" page by default when edition is OpenSource', async () => { + test('shows OSS subtitle and OSS card descriptions when edition is OpenSource', async () => { config.pluginAdminExternalManageEnabled = false; renderPage(); - // Add new connection card - expect(await screen.findByText('Add new connection')).toBeVisible(); - expect(await screen.findByText('View configured data sources')).toBeVisible(); - - // Heading expect(await screen.findByText('Welcome to Connections')).toBeVisible(); expect( await screen.findByText( 'Manage your data source connections in one place. Use this page to add a new data source or manage your existing connections.' ) ).toBeVisible(); + + // OSS-specific card subtitle for "Add new connection" + expect(await screen.findByText('Connect to a new data source')).toBeVisible(); + // OSS-specific title for "Data sources" + expect(await screen.findByText('View configured data sources')).toBeVisible(); + }); + + test('only shows cards for nav items present in the connections nav section', async () => { + // Store with a minimal connections nav (e.g. OSS - only core items) + const minimalStore = configureStore({ + navIndex: { + ...navIndex, + connections: { + ...navIndex.connections, + children: [ + { + id: 'connections-add-new-connection', + text: 'Add new connection', + url: '/connections/add-new-connection', + }, + { id: 'connections-datasources', text: 'Data sources', url: '/connections/datasources' }, + ], + }, + }, + plugins: getPluginsStateMock([]), + }); + renderPage(ROUTES.Base, minimalStore); + + expect(await screen.findByText('Add new connection')).toBeVisible(); + expect(await screen.findByText('Data sources')).toBeVisible(); + expect(screen.queryByText('Collector')).not.toBeInTheDocument(); + expect(screen.queryByText('Integrations')).not.toBeInTheDocument(); + expect(screen.queryByText('Private data source connect')).not.toBeInTheDocument(); }); test('renders the correct tab even if accessing it with a "sub-url"', async () => { diff --git a/public/app/features/connections/components/PageCard/CardData.ts b/public/app/features/connections/components/PageCard/CardData.ts deleted file mode 100644 index 670072a5ee9db..0000000000000 --- a/public/app/features/connections/components/PageCard/CardData.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IconName } from '@grafana/data'; -import { t } from '@grafana/i18n'; - -type CardData = { - text: string; - subTitle: string; - url: string; - icon: IconName; -}; - -export function getCloudCardData(): CardData[] { - return [ - { - text: t('connections.cloud.connections-home-page.add-new-connection.title', 'Add new connection'), - subTitle: t( - 'connections.cloud.connections-home-page.add-new-connection.subtitle', - 'Connect data to Grafana through data sources, integrations and apps' - ), - url: '/connections/add-new-connection', - icon: 'plus-circle', - }, - { - text: t('connections.cloud.connections-home-page.collector.title', 'Collector'), - subTitle: t( - 'connections.cloud.connections-home-page.collector.subtitle', - 'Manage the configuration of Grafana Alloy, our distribution of the OpenTelemetry Collector' - ), - url: '/a/grafana-collector-app', - icon: 'frontend-observability', - }, - { - text: t('connections.cloud.connections-home-page.data-sources.title', 'Data sources'), - subTitle: t( - 'connections.cloud.connections-home-page.data-sources.subtitle', - 'Manage your existing data source connections' - ), - url: '/connections/datasources', - icon: 'database', - }, - { - text: t('connections.cloud.connections-home-page.integrations.title', 'Integrations'), - subTitle: t('connections.cloud.connections-home-page.integrations.subtitle', 'Manage your active integrations'), - url: '/connections/infrastructure', - icon: 'apps', - }, - { - text: t( - 'connections.cloud.connections-home-page.private-data-source-connections.title', - 'Private data source connect' - ), - subTitle: t( - 'connections.cloud.connections-home-page.private-data-source-connections.subtitle', - 'Manage your private network connections for data sources' - ), - url: '/connections/private-data-source-connections', - icon: 'sitemap', - }, - ]; -} - -export function getOssCardData(): CardData[] { - return [ - { - text: t('connections.oss.connections-home-page.add-new-connection.title', 'Add new connection'), - subTitle: t('connections.oss.connections-home-page.add-new-connection.subtitle', 'Connect to a new data source'), - url: '/connections/add-new-connection', - icon: 'plus-circle', - }, - { - text: t('connections.oss.connections-home-page.data-sources.title', 'View configured data sources'), - subTitle: t( - 'connections.oss.connections-home-page.data-sources.subtitle', - 'Manage your existing data source connections' - ), - url: '/connections/datasources', - icon: 'database', - }, - ]; -} diff --git a/public/app/features/connections/components/PageCard/CardMetadata.ts b/public/app/features/connections/components/PageCard/CardMetadata.ts new file mode 100644 index 0000000000000..e2934bab03837 --- /dev/null +++ b/public/app/features/connections/components/PageCard/CardMetadata.ts @@ -0,0 +1,76 @@ +import type { IconName } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +type CardMetadata = { + icon: IconName; + subTitle: string; + text?: string; +}; + +// Visual metadata for connections nav items keyed by URL. This map intentionally +// overrides both plugin standalone pages (which carry no icon/subTitle through +// the nav tree) and some core nav items (Add new connection, Data sources) so the +// landing-page cards use the correct icon and copy per edition. +// isOnPrem mirrors !config.pluginAdminExternalManageEnabled. +export function getConnectionsCardMetadata(isOnPrem: boolean): Record<string, CardMetadata> { + return { + '/connections/add-new-connection': { + icon: 'plus-circle', + subTitle: isOnPrem + ? t('connections.oss.connections-home-page.add-new-connection.subtitle', 'Connect to a new data source') + : t( + 'connections.cloud.connections-home-page.add-new-connection.subtitle', + 'Connect data to Grafana through data sources, integrations and apps' + ), + }, + '/connections/datasources': { + icon: 'database', + text: isOnPrem + ? t('connections.oss.connections-home-page.data-sources.title', 'View configured data sources') + : undefined, + subTitle: t( + 'connections.cloud.connections-home-page.data-sources.subtitle', + 'Manage your existing data source connections' + ), + }, + '/a/grafana-collector-app': { + icon: 'frontend-observability', + subTitle: t( + 'connections.cloud.connections-home-page.collector.subtitle', + 'Manage the configuration of Grafana Alloy, our distribution of the OpenTelemetry Collector' + ), + }, + '/a/grafana-collector-app/alloy': { + icon: 'frontend-observability', + subTitle: t( + 'connections.cloud.connections-home-page.collector-setup.subtitle', + 'Configure and manage your telemetry collectors' + ), + }, + '/a/grafana-collector-app/instrumentation-hub': { + icon: 'sitemap', + subTitle: t( + 'connections.cloud.connections-home-page.instrumentation-hub.subtitle', + 'Instrument all your services with a single click using ongoing instrumentation and Kubernetes monitoring.' + ), + }, + '/a/grafana-collector-app/fleet-management': { + icon: 'file-alt', + subTitle: t( + 'connections.cloud.connections-home-page.fleet-management.subtitle', + 'Manage your collector inventory and remotely configure your fleet' + ), + }, + '/connections/infrastructure': { + icon: 'apps', + subTitle: t('connections.cloud.connections-home-page.integrations.subtitle', 'Manage your active integrations'), + }, + '/connections/private-data-source-connections': { + icon: 'lock', + subTitle: t( + 'connections.cloud.connections-home-page.private-data-source-connections.subtitle', + 'Manage your private network connections for data sources' + ), + }, + }; +} diff --git a/public/app/features/connections/mocks/store.navIndex.mock.ts b/public/app/features/connections/mocks/store.navIndex.mock.ts index f0ea6c8b06c95..cbb89737e8734 100644 --- a/public/app/features/connections/mocks/store.navIndex.mock.ts +++ b/public/app/features/connections/mocks/store.navIndex.mock.ts @@ -247,6 +247,12 @@ export const navIndex: NavIndex = { subTitle: 'Browse and create new connections', url: '/connections/add-new-connection', }, + { + id: 'standalone-plugin-page-/a/grafana-collector-app', + text: 'Collector', + url: '/a/grafana-collector-app', + pluginId: 'grafana-collector-app', + }, { id: 'connections-datasources', text: 'Data sources', @@ -255,10 +261,16 @@ export const navIndex: NavIndex = { }, { id: 'standalone-plugin-page-/connections/infrastructure', - text: 'Infrastructure', + text: 'Integrations', url: '/connections/infrastructure', pluginId: 'grafana-easystart-app', }, + { + id: 'standalone-plugin-page-/connections/private-data-source-connections', + text: 'Private data source connect', + url: '/connections/private-data-source-connections', + pluginId: 'grafana-pdc-app', + }, ], parentItem: { id: 'home', @@ -276,7 +288,7 @@ export const navIndex: NavIndex = { }, 'standalone-plugin-page-/connections/infrastructure': { id: 'standalone-plugin-page-/connections/infrastructure', - text: 'Infrastructure', + text: 'Integrations', url: '/connections/infrastructure', pluginId: 'grafana-easystart-app', }, diff --git a/public/app/features/connections/pages/ConnectionsHomePage.tsx b/public/app/features/connections/pages/ConnectionsHomePage.tsx index 8a630f35aac92..e696fa0c76764 100644 --- a/public/app/features/connections/pages/ConnectionsHomePage.tsx +++ b/public/app/features/connections/pages/ConnectionsHomePage.tsx @@ -1,20 +1,44 @@ import { css } from '@emotion/css'; -import { type GrafanaTheme2 } from '@grafana/data'; +import { type GrafanaTheme2, type IconName, isIconName } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { useSelector } from 'app/types/store'; -import { getCloudCardData, getOssCardData } from '../components/PageCard/CardData'; +import { getConnectionsCardMetadata } from '../components/PageCard/CardMetadata'; import PageCard from '../components/PageCard/PageCard'; +const FALLBACK_ICON: IconName = 'plug'; + +function resolveIcon(metaIcon: IconName | undefined, navIcon: string | undefined): IconName { + if (metaIcon) { + return metaIcon; + } + if (navIcon && isIconName(navIcon)) { + return navIcon; + } + return FALLBACK_ICON; +} + export default function ConnectionsHomePage() { const styles = useStyles2(getStyles); const isOnPrem = !config.pluginAdminExternalManageEnabled; - - let cardsData = isOnPrem ? getOssCardData() : getCloudCardData(); + const cardMetadata = getConnectionsCardMetadata(isOnPrem); + const navIndex = useSelector((state) => state.navIndex); + const cardsData = (navIndex['connections']?.children ?? []) + .filter((item) => item.url) + .map((item) => { + const meta = cardMetadata[item.url!]; + return { + ...item, + text: meta?.text ?? item.text, + icon: resolveIcon(meta?.icon, item.icon), + subTitle: meta?.subTitle ?? item.subTitle ?? '', + }; + }); return ( <Page @@ -42,15 +66,15 @@ export default function ConnectionsHomePage() { </Trans> )} </p> - {cardsData && cardsData.length > 0 && ( + {cardsData.length > 0 && ( <section className={styles.cardsSection}> - {cardsData?.map((child, index) => ( + {cardsData.map((child, index) => ( <PageCard - key={index} + key={child.id ?? index} title={child.text} description={child.subTitle} icon={child.icon} - url={child.url} + url={child.url!} index={index} /> ))} diff --git a/pub ... [truncated]
← Back to Alerts View on GitHub →