Information disclosure / Insecure error handling

MEDIUM
grafana/grafana
Commit: 71bd5dda368d
Affected: Grafana 12.0.0 through 12.3.x (prior to 12.4.0)
2026-06-04 15:53 UTC

Description

The commit fixes an information disclosure / insecure error handling path in plugin settings retrieval. Previously, requesting plugin settings for a plugin that is not installed could surface an error (e.g., Unknown Plugin) including error details or signaling a 404 condition to the UI, potentially enabling plugin-availability enumeration or unrelated error leakage. The fix changes usePluginSettings to swallow 404 errors by returning undefined instead of surfacing an error, effectively treating a missing plugin as a normal absence. Additional small hardening changes include guarding getAppPluginEnabled with an empty-pluginId check and correcting a log message typo. Collectively, these changes reduce information leakage about which plugins are installed and improve error handling for missing plugins.

Proof of Concept

POC steps and code (conceptual): - Objective: Demonstrate the potential information disclosure via plugin settings retrieval and how the fix mitigates it. - Context: In Grafana 12.x before the fix, querying settings for a non-installed plugin could surface an error (e.g., Unknown Plugin) with a 404 status, which could be used to enumerate installed vs non-installed plugins based on error signaling in the UI. - Reproduction (vulnerable behavior): 1) Enumerate a list of plausible plugin IDs (e.g., ["grafana-piechart-panel", "myorg-test-app", "nonexistent-plugin"]). 2) From a page/component that calls usePluginSettings(pluginId), trigger a request to GET /api/plugins/{pluginId}/settings. 3) If the plugin is not installed, observe the UI showing an error or a detailed error message (containing 404 status or Unknown Plugin) that can reveal plugin absence/presence. - Reproduction (fixed behavior in this commit): 1) Repeat the steps above with Grafana 12.4.0 or later. 2) For a non-installed plugin, the request resolves to undefined with no error surfaced to the UI, preventing leakage of plugin existence. Code-style repro snippet (Node.js fetch-based, environment-agnostic): const fetch = require('node-fetch'); const pluginIds = ['grafana-piechart-panel', 'myorg-test-app', 'nonexistent-plugin']; async function probe(id) { const res = await fetch(`https://grafana.example.com/api/plugins/${id}/settings`, { headers: { Authorization: 'Bearer <token>' } }); if (res.status === 200) { const meta = await res.json(); console.log(id, 'installed', meta); } else if (res.status === 404) { // Pre-fix: would surface an error; Post-fix: treated as not installed with no error console.log(id, 'not installed (404)'); } else { console.log(id, 'other error', res.status); } } pluginIds.forEach(id => probe(id)); Notes: - In a browser UI, the PoC manifests as an observable error for 404 on the vulnerable versions; after the fix, the same 404 yields no error and value is undefined. This makes automated plugin enumeration harder and reduces UI error leakage.

Commit Details

Author: Gilles De Mey

Date: 2026-06-04 15:06 UTC

Message:

Alerting: Use Grafana runtime for plugin settings (#124488)

Triage Assessment

Vulnerability Type: Information disclosure / Insecure error handling

Confidence: MEDIUM

Reasoning:

The commit adds handling for 404 errors in plugin settings retrieval, treating missing plugin as a normal absence (undefined value) instead of surfacing an error. This reduces information disclosure and error signaling about unavailable plugins, which is a security-relevant improvement. Other changes are code maintenance or tests and do not clearly fix security vulnerabilities.

Verification Assessment

Vulnerability Type: Information disclosure / Insecure error handling

Confidence: MEDIUM

Affected Versions: Grafana 12.0.0 through 12.3.x (prior to 12.4.0)

Code Diff

diff --git a/packages/grafana-runtime/src/services/pluginSettings/hooks.test.ts b/packages/grafana-runtime/src/services/pluginSettings/hooks.test.ts index ff3372023296d..b8c47c270d3d0 100644 --- a/packages/grafana-runtime/src/services/pluginSettings/hooks.test.ts +++ b/packages/grafana-runtime/src/services/pluginSettings/hooks.test.ts @@ -1,8 +1,18 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { useAppPluginEnabled } from './hooks'; +import { PluginType, type PluginMeta } from '@grafana/data'; + +import { getPluginSettings } from './getPluginSettings'; +import { useAppPluginEnabled, usePluginSettings } from './hooks'; import { getAppPluginEnabled } from './settings'; +jest.mock('./getPluginSettings', () => ({ + ...jest.requireActual('./getPluginSettings'), + getPluginSettings: jest.fn(), +})); + +const getPluginSettingsMock = jest.mocked(getPluginSettings); + jest.mock('./settings', () => ({ ...jest.requireActual('./settings.ts'), getAppPluginEnabled: jest.fn(), @@ -65,4 +75,83 @@ describe('hooks', () => { expect(result.current.value).toBeUndefined(); }); }); + + describe('usePluginSettings', () => { + const mockPluginMeta: PluginMeta = { + id: 'myorg-test-app', + name: 'Test App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { name: 'John Doe' }, + description: 'This is my test app', + links: [], + logos: { large: 'logo-large.png', small: 'logo-small.png' }, + screenshots: [], + updated: new Date().toISOString(), + version: '1.0.0', + }, + }; + + it('should return loading state initially', async () => { + getPluginSettingsMock.mockResolvedValue(mockPluginMeta); + + const { result } = renderHook(() => usePluginSettings('myorg-test-app')); + + expect(result.current.loading).toEqual(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + + // suppress act() warning + await waitFor(() => expect(result.current.loading).toEqual(false)); + }); + + it('should return the plugin meta after loading', async () => { + getPluginSettingsMock.mockResolvedValue(mockPluginMeta); + + const { result } = renderHook(() => usePluginSettings('myorg-test-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toStrictEqual(mockPluginMeta); + }); + + it('should return undefined value without error when pluginId is empty', async () => { + const { result } = renderHook(() => usePluginSettings('')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + expect(getPluginSettingsMock).not.toHaveBeenCalled(); + }); + + it('should return undefined value without error on a 404', async () => { + // getPluginSettings wraps fetch errors as: new Error('Unknown Plugin', { cause: fetchError }) + const fetchError = { status: 404, data: {} }; + getPluginSettingsMock.mockRejectedValue(new Error('Unknown Plugin', { cause: fetchError })); + + const { result } = renderHook(() => usePluginSettings('myorg-test-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.error).toBeUndefined(); + expect(result.current.value).toBeUndefined(); + }); + + it('should surface non-404 errors via the error field', async () => { + const fetchError = { status: 500, data: {} }; + const thrownError = new Error('Unknown Plugin', { cause: fetchError }); + getPluginSettingsMock.mockRejectedValue(thrownError); + + const { result } = renderHook(() => usePluginSettings('myorg-test-app')); + + await waitFor(() => expect(result.current.loading).toEqual(false)); + + expect(result.current.error).toStrictEqual(thrownError); + expect(result.current.value).toBeUndefined(); + }); + }); }); diff --git a/packages/grafana-runtime/src/services/pluginSettings/hooks.ts b/packages/grafana-runtime/src/services/pluginSettings/hooks.ts index 375cf5297ae80..98a324e00ccb9 100644 --- a/packages/grafana-runtime/src/services/pluginSettings/hooks.ts +++ b/packages/grafana-runtime/src/services/pluginSettings/hooks.ts @@ -1,5 +1,10 @@ import { useAsync } from 'react-use'; +import { type PluginMeta } from '@grafana/data'; + +import { isFetchError } from '../backendSrv'; + +import { getPluginSettings } from './getPluginSettings'; import { getAppPluginEnabled } from './settings'; /** @@ -12,3 +17,30 @@ export function useAppPluginEnabled(pluginId: string) { const { loading, error, value } = useAsync(async () => getAppPluginEnabled(pluginId), [pluginId]); return { loading, error, value }; } + +/** + * Hook that fetches the full plugin settings (PluginMeta) for a given plugin ID. + * @param pluginId - The ID of the plugin. + * @returns loading, error, value where value is the PluginMeta or undefined if not found. + * A 404 (plugin not installed) resolves to value: undefined with no error. + * Other failures (auth errors, server errors) are surfaced via the error field. + */ +export function usePluginSettings(pluginId: string) { + const { loading, error, value } = useAsync(async (): Promise<PluginMeta | undefined> => { + if (!pluginId) { + return undefined; + } + try { + return await getPluginSettings(pluginId); + } catch (err) { + // getLegacySettings wraps the raw fetch error as the `cause`. A 404 means the + // plugin is simply not installed — treat it as a normal absence, not an error. + const cause = err instanceof Error ? err.cause : err; + if (isFetchError(cause) && cause.status === 404) { + return undefined; + } + throw err; + } + }, [pluginId]); + return { loading, error, value }; +} diff --git a/packages/grafana-runtime/src/services/pluginSettings/settings.test.ts b/packages/grafana-runtime/src/services/pluginSettings/settings.test.ts index f9bbaf963288b..03b649343eb4d 100644 --- a/packages/grafana-runtime/src/services/pluginSettings/settings.test.ts +++ b/packages/grafana-runtime/src/services/pluginSettings/settings.test.ts @@ -150,7 +150,7 @@ describe('settings', () => { expect(enabled).toEqual(false); expect(logger.logError).toHaveBeenCalledWith( - new Error('isAppPluginEnabled: failed because of unknonw reason'), + new Error('isAppPluginEnabled: failed because of unknown reason'), { pluginId: 'myorg-test-app' } ); expect(logger.logWarning).not.toHaveBeenCalled(); diff --git a/packages/grafana-runtime/src/services/pluginSettings/settings.ts b/packages/grafana-runtime/src/services/pluginSettings/settings.ts index 05949954d286f..4fc79f989e63f 100644 --- a/packages/grafana-runtime/src/services/pluginSettings/settings.ts +++ b/packages/grafana-runtime/src/services/pluginSettings/settings.ts @@ -5,6 +5,10 @@ import { logPluginSettingsError, logPluginSettingsWarning } from './logging'; import { isAuthError } from './utils'; export async function getAppPluginEnabled(pluginId: string): Promise<boolean> { + if (!pluginId) { + return false; + } + const app = await getPluginSettings(pluginId); if (!app) { return false; @@ -26,7 +30,7 @@ export async function isAppPluginEnabled(pluginId: string): Promise<boolean> { if (isAuthError(error)) { logPluginSettingsWarning(`isAppPluginEnabled: failed because auth denied`, { pluginId }); } else { - logPluginSettingsError(`isAppPluginEnabled: failed because of unknonw reason`, error, { pluginId }); + logPluginSettingsError(`isAppPluginEnabled: failed because of unknown reason`, error, { pluginId }); } } return false; diff --git a/packages/grafana-runtime/src/unstable.ts b/packages/grafana-runtime/src/unstable.ts index 374bcc1528ece..f31406bb94ed7 100644 --- a/packages/grafana-runtime/src/unstable.ts +++ b/packages/grafana-runtime/src/unstable.ts @@ -15,6 +15,7 @@ export { defineFeatureEvents } from './analyticsFramework/main'; export type { EventProperty, Event } from './analyticsFramework/types'; export { getPluginSettings } from './services/pluginSettings/getPluginSettings'; export { updateAppPluginSettings } from './services/pluginSettings/updateAppPluginSettings'; +export { usePluginSettings } from './services/pluginSettings/hooks'; export { getDataSourceInstanceSettings, reloadDataSourceInstanceSettings } from './services/dataSource/settings'; export { getDataSourceInstance, registerRuntimeDataSourceInstance } from './services/dataSource/dataSource'; export { diff --git a/public/app/features/alerting/unified/api/pluginsApi.ts b/public/app/features/alerting/unified/api/pluginsApi.ts deleted file mode 100644 index 8c15ed9f024c7..0000000000000 --- a/public/app/features/alerting/unified/api/pluginsApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type PluginMeta } from '@grafana/data'; - -import { alertingApi } from './alertingApi'; - -export const pluginsApi = alertingApi.injectEndpoints({ - endpoints: (build) => ({ - getPluginSettings: build.query<PluginMeta, string>({ - query: (pluginId) => ({ - url: `/api/plugins/${pluginId}/settings`, - notificationOptions: { - showErrorAlert: false, - }, - }), - // Keep plugin settings cached for the entire session - keepUnusedDataFor: 3600, // 1 hour in seconds - }), - }), -}); - -export const { useGetPluginSettingsQuery } = pluginsApi; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index 4bf49f2a7782a..629ed74273b83 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -15,6 +15,7 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), usePluginLinks: jest.fn(), useReturnToPrevious: jest.fn(), + useAppPluginEnabled: jest.fn().mockReturnValue({ loading: false, error: undefined, value: false }), })); jest.mock('../../hooks/useIsRuleEditable'); diff --git a/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.test.tsx b/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.test.tsx index 71bd04b670541..137198246dd9b 100644 --- a/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.test.tsx +++ b/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.test.tsx @@ -97,6 +97,9 @@ describe('useRuleAdministrationAbility', () => { // Snapshot the initial loading state before the ruler resolves expect(result.current).toMatchSnapshot(); + + // Drain the async queue so the test does not leave in-flight state updates + await waitFor(() => expect(result.current.loading).toBe(false)); }); it('returns INSUFFICIENT_PERMISSIONS when user lacks folder edit permission', async () => { @@ -410,9 +413,12 @@ function makePromRule(overrides?: Partial<GrafanaPromRuleDTO>): GrafanaPromRuleD } describe('usePromRuleAdministrationAbility', () => { - it('returns all NOT_SUPPORTED when skipToken is passed', () => { + it('returns all NOT_SUPPORTED when skipToken is passed', async () => { const { result } = renderHook(() => usePromRuleAdministrationAbility(skipToken), { wrapper: wrapper() }); + // Drain the async queue from useRulePluginImmutability's useAsync call + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(isNotSupported(result.current.update)).toBe(true); expect(isNotSupported(result.current.delete)).toBe(true); expect(isNotSupported(result.current.deletePermanently)).toBe(true); diff --git a/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.utils.ts b/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.utils.ts index 9af38fd31cbc6..14df2228ab199 100644 --- a/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.utils.ts +++ b/public/app/features/alerting/unified/hooks/abilities/rules/ruleAbilities.utils.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import { useAppPluginEnabled } from '@grafana/runtime'; import { contextSrv as ctx } from 'app/core/services/context_srv'; import { useFolder } from 'app/features/alerting/unified/hooks/useFolder'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; @@ -7,7 +8,6 @@ import { AccessControlAction } from 'app/types/accessControl'; import { type GrafanaPromRuleDTO, type RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../../../api/alertmanagerApi'; -import { useGetPluginSettingsQuery } from '../../../api/pluginsApi'; import { getRulesPermissions } from '../../../utils/access-control'; import { isAdmin } from '../../../utils/misc'; import { getRulePluginOrigin } from '../../../utils/rules'; @@ -171,10 +171,8 @@ export function useRulePluginImmutability(rule: RulerRuleDTO | GrafanaPromRuleDT pluginLoading: boolean; } { const pluginOrigin = getRulePluginOrigin(rule); - const { data: pluginSettings, isLoading } = useGetPluginSettingsQuery(pluginOrigin?.pluginId ?? '', { - skip: !pluginOrigin?.pluginId, - }); - const isPluginInstalled = pluginSettings?.enabled ?? false; + // Empty string returns false immediately without a network call + const { value: isPluginInstalled = false, loading: isLoading } = useAppPluginEnabled(pluginOrigin?.pluginId ?? ''); return useMemo( () => ({ diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 75f2b8f87115f..b5478875cfad3 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { config } from '@grafana/runtime'; +import { config, useAppPluginEnabled } from '@grafana/runtime'; import { contextSrv as ctx } from 'app/core/services/context_srv'; import { PERMISSIONS_CONTACT_POINTS_READ } from 'app/features/alerting/unified/components/contact-points/permissions'; import { @@ -14,7 +14,6 @@ import { type CombinedRule, type RuleGroupIdentifierV2 } from 'app/types/unified import { type GrafanaPromRuleDTO, type RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../api/alertmanagerApi'; -import { useGetPluginSettingsQuery } from '../api/pluginsApi'; import { useAlertmanager } from '../state/AlertmanagerContext'; import { getInstancesPermissions, getNotificationsPermissions, getRulesP ... [truncated]
← Back to Alerts View on GitHub →