Information Disclosure / Cache mishandling

MEDIUM
grafana/grafana
Commit: 3b3e1d63f532
Affected: < 12.4.0
2026-04-08 22:11 UTC

Description

The commit changes public dashboards to avoid reusing a session-scoped scene cache and to build the dashboard scene from API data instead of cached scene data. Before this fix, public dashboards could be loaded from a per-session cache, risking information disclosure or leakage across sessions if a cached scene containing user/session-specific data was reused for another user or session. The fix ensures public dashboards are not pulled from the session cache and are constructed from API data, reducing stale or cross-session data exposure and aligning authorization context with the API response. This is a genuine vulnerability fix for cache mishandling related to public dashboards.

Proof of Concept

PoC (pre-fix scenario): - Environment: Grafana 12.x prior to this commit, with a per-session in-memory cache for dashboard scenes. A public dashboard route is available via a token (e.g., /dashboards/public/{token}). - Step 1 (Session A, logged in): Open the public dashboard using token T. The app loads data from the API and stores the built scene into a session-scoped cache keyed by T (loader.setSceneCache(T, scene)). The scene contains the dashboard state derived from Session A (including panel data, titles, etc.). - Step 2 (Session B, not authenticated or different user): Open the same public dashboard using the same token T in a separate browser session or after a new login. The application path would fetch from cache (getSceneFromCache(T)) and may reuse Session A’s scene data for rendering, showing A’s data to B due to cached scene reuse. - Step 3: The UI renders the cached scene, exposing data/data-derived UI state from Session A to Session B via the public route. - Step 4 (Impact): A cross-session information disclosure occurs via cache reuse for public dashboards, potentially exposing user/session-specific data. - After the fix (as implemented in this commit): The public route skips scene cache (skipSceneCache) and always builds the scene from the API response, ensuring data is scoped to the current access token and API data, preventing cross-session leakage. Code sketch (conceptual): // Pre-fix behavior (vulnerable): if (route === Public) { const fromCache = getSceneFromCache(uid); if (fromCache && fromCache.state.version === rsp.dashboard.version) { return fromCache; // risk: session-scoped data reused for public route } } // Post-fix behavior (secure): const skipSceneCache = route === Public; if (!skipSceneCache) { const fromCache = getSceneFromCache(uid); // allow cache reuse only when not public if (fromCache && fromCache.state.version === rsp.dashboard.version) { return fromCache; } } // Load from API and build scene; do not cache for public dashboards if (uid && !skipSceneCache) { setSceneCache(uid, scene); }

Commit Details

Author: Michael Mandrus

Date: 2026-04-08 21:35 UTC

Message:

Public Dashboards: Fix issues navigating to public dashboards from a logged-in session (#121017) * don't use the cache when loading public dashboards * further fixes * make comment shorter * remove unneeded guards * fix test * lint

Triage Assessment

Vulnerability Type: Information Disclosure / Cache mishandling

Confidence: MEDIUM

Reasoning:

The changes ensure public dashboards are not loaded from a session cache and that a public dashboard is built from API data instead of cached scene data. This reduces the risk of stale or cross-session data being presented to users (information disclosure / cache-based leakage) and fixes navigation issues that could arise from using wrong cached state. The patch explicitly adjusts caching behavior for public dashboards and handles access token handling for public routes, both of which have security implications regarding data exposure and correct authorization context.

Verification Assessment

Vulnerability Type: Information Disclosure / Cache mishandling

Confidence: MEDIUM

Affected Versions: < 12.4.0

Code Diff

diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index e8110cdd08c8e..6e15eaea033b6 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -544,6 +544,41 @@ describe('DashboardScenePageStateManager v1', () => { expect(loader.getSceneFromCache('fake-dash').state.title).toBe('Dashboard 2'); }); + it('public dashboard: should build scene from API and not reuse or keep stale scene cache', () => { + const loader = new DashboardScenePageStateManager({}); + const accessToken = 'public-access-token'; + const staleScene = new DashboardScene( + { + title: 'Stale cached title', + uid: 'underlying-dash-uid', + meta: { created: 'same-created' }, + version: 2, + }, + 'v1' + ); + + loader.setSceneCache(accessToken, staleScene); + + const rsp: DashboardDTO = { + meta: { created: 'same-created' }, + dashboard: { + uid: 'underlying-dash-uid', + title: 'Fresh from API', + schemaVersion: 38, + version: 2, + } as DashboardDataDTO, + }; + + const scene = loader.transformResponseToScene(rsp, { + uid: accessToken, + route: DashboardRoutes.Public, + }); + + expect(rsp.dashboard?.title).toBe('Fresh from API'); + expect(scene).not.toBe(staleScene); + expect(scene?.state.title).toBe('Fresh from API'); + }); + it('should take scene from cache if it exists', async () => { setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', version: 10 }, meta: {} }); @@ -763,6 +798,45 @@ describe('DashboardScenePageStateManager v2', () => { expect(getDashSpy).toHaveBeenCalledTimes(1); }); + it('public dashboard: should build scene from API and not reuse or keep stale scene cache', () => { + const loader = new DashboardScenePageStateManagerV2({}); + const accessToken = 'public-access-token'; + const generation = 4; + const staleScene = new DashboardScene( + { + title: 'Stale cached title', + uid: 'dash-uid', + meta: {}, + version: generation, + }, + 'v2' + ); + + loader.setSceneCache(accessToken, staleScene); + + const rsp: DashboardWithAccessInfo<DashboardV2Spec> = { + access: {}, + apiVersion: 'v2beta1', + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'dash', + creationTimestamp: '', + resourceVersion: '1', + generation, + }, + spec: { ...defaultDashboardV2Spec(), title: 'Fresh from API' }, + }; + + const scene = loader.transformResponseToScene(rsp, { + uid: accessToken, + route: DashboardRoutes.Public, + }); + + expect(rsp.spec?.title).toBe('Fresh from API'); + expect(scene).not.toBe(staleScene); + expect(scene?.state.title).toBe('Fresh from API'); + }); + it('should register report render readiness observer for render-authenticated normal route', async () => { const getDashSpy = jest.fn(); setupDashboardAPI( diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index f21be346eaddd..27d0e4e855f2f 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -522,19 +522,24 @@ abstract class DashboardScenePageStateManagerBase<T> export class DashboardScenePageStateManager extends DashboardScenePageStateManagerBase<DashboardDTO> { transformResponseToScene(rsp: DashboardDTO | null, options: LoadDashboardOptions): DashboardScene | null { - const fromCache = this.getSceneFromCache(options.uid); + // Public dashboards are not part of a session and therefore should not use the cache + const skipSceneCache = options.route === DashboardRoutes.Public; - if ( - fromCache && - fromCache.state.version === rsp?.dashboard.version && - fromCache.state.meta.created === rsp?.meta.created - ) { - const profiler = getDashboardSceneProfiler(); - profiler.setMetadata({ - dashboardUID: fromCache.state.uid, - dashboardTitle: fromCache.state.title, - }); - return fromCache; + if (!skipSceneCache) { + const fromCache = this.getSceneFromCache(options.uid); + + if ( + fromCache && + fromCache.state.version === rsp?.dashboard.version && + fromCache.state.meta.created === rsp?.meta.created + ) { + const profiler = getDashboardSceneProfiler(); + profiler.setMetadata({ + dashboardUID: fromCache.state.uid, + dashboardTitle: fromCache.state.title, + }); + return fromCache; + } } if (rsp?.dashboard) { @@ -553,8 +558,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag scene.setState({ isDirty: true }); } - // Cache scene only if not coming from Explore, we don't want to cache temporary dashboard - if (options.uid) { + // We don't want to cache temporary dashboards (e.g from Explore) or public dashboards + if (options.uid && !skipSceneCache) { this.setSceneCache(options.uid, scene); } @@ -918,22 +923,27 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan rsp: DashboardWithAccessInfo<DashboardV2Spec> | null, options: LoadDashboardOptions ): DashboardScene | null { - const fromCache = this.getSceneFromCache(options.uid); + // Public dashboards are not part of a session and therefore should not use the cache + const skipSceneCache = options.route === DashboardRoutes.Public; - if (fromCache && fromCache.state.version === rsp?.metadata.generation) { - const profiler = getDashboardSceneProfiler(); - profiler.setMetadata({ - dashboardUID: fromCache.state.uid, - dashboardTitle: fromCache.state.title, - }); - return fromCache; + if (!skipSceneCache) { + const fromCache = this.getSceneFromCache(options.uid); + + if (fromCache && fromCache.state.version === rsp?.metadata.generation) { + const profiler = getDashboardSceneProfiler(); + profiler.setMetadata({ + dashboardUID: fromCache.state.uid, + dashboardTitle: fromCache.state.title, + }); + return fromCache; + } } if (rsp) { const scene = transformSaveModelSchemaV2ToScene(rsp, options); - // Cache scene only if not coming from Explore, we don't want to cache temporary dashboard - if (options.uid) { + // We don't want to cache temporary dashboards (e.g from Explore) or public dashboards + if (options.uid && !skipSceneCache) { this.setSceneCache(options.uid, scene); } diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx index 3de00b2fadfea..46ead1f28579f 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { of } from 'rxjs'; import { render } from 'test/test-utils'; @@ -131,6 +131,19 @@ describe('PublicDashboardScenePage', () => { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); }); + it('syncs config.publicDashboardAccessToken from the URL and restores the previous value on unmount', async () => { + config.publicDashboardAccessToken = 'token-before-navigation'; + const view = setup('token-from-route'); + + await waitFor(() => { + expect(config.publicDashboardAccessToken).toBe('token-from-route'); + }); + + view.unmount(); + + expect(config.publicDashboardAccessToken).toBe('token-before-navigation'); + }); + it('can render public dashboard', async () => { setup(); diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx index 4996f7f88cb32..81000e48f7925 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom-v5-compat'; import { type GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { type SceneComponentProps, UrlSyncContextProvider } from '@grafana/scenes'; import { Alert, Box, Icon, Stack, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -37,10 +38,17 @@ export function PublicDashboardScenePage({ route }: Props) { const { dashboard, isLoading, loadError } = stateManager.useState(); useEffect(() => { + // Full page loads set this via boot data, but client-side navigation must set it here or panel queries will not use /api/public/dashboards/{token}/... + const previousToken = config.publicDashboardAccessToken; + if (accessToken) { + config.publicDashboardAccessToken = accessToken; + } + stateManager.loadDashboard({ uid: accessToken, route: DashboardRoutes.Public }); return () => { stateManager.clearState(); + config.publicDashboardAccessToken = previousToken; }; }, [stateManager, accessToken, route.routeName]); diff --git a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx index 2c24e926c9a9d..93a04d4438152 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx @@ -1,9 +1,11 @@ import { screen, waitFor } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom-v5-compat'; +import { of } from 'rxjs'; import { render } from 'test/test-utils'; +import { getDefaultTimeRange, LoadingState, type PanelData } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; +import { locationService, setRunRequest } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { type DashboardDTO, DashboardRoutes } from 'app/types/dashboard'; @@ -26,6 +28,16 @@ jest.mock('react-router-dom-v5-compat', () => ({ useParams: () => ({ accessToken: 'an-access-token' }), })); +const runRequestMock = jest.fn().mockReturnValue( + of<PanelData>({ + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + annotations: [], + }) +); +setRunRequest(runRequestMock); + function setup(props: Partial<PublicDashboardPageProxyProps>) { return render( <Routes>
← Back to Alerts View on GitHub →