Information Disclosure via Public Dashboards
Description
The patch adds guards and behavior changes around public dashboards to prevent unintended data fetches and potential information disclosure. Specifically:
- publicDashboardQueryHandler now returns an empty result and does not trigger a backend fetch when panelId is undefined or NaN, guarding against broken or malicious requests to /api/public/dashboards/{token}/panels/{panelId}/query.
- In the v2 serialization path, public dashboard mode forces QueryVariable refresh to never, preventing automatic data fetches from variables when exposed publicly.
These changes address edge cases where public dashboards could inadvertently query data and expose information, or where invalid panel identifiers could invoke unintended backend behavior.
Overall, this is a vulnerability fix intended to prevent information disclosure via public dashboards by avoiding unnecessary/invalid queries and by freezing variable refresh in public mode.
Proof of Concept
Proof-of-concept (PoC) to illustrate the vulnerable path before the fix:
Prerequisites:
- Grafana instance with public dashboards enabled and a public dashboard token (e.g., test-token).
- Public access to a dashboard that can be accessed at the path /api/public/dashboards/{token}.
1) Triggering the problematic path with an undefined panelId (vulnerability demonstration):
- Before the fix, an attacker could attempt to query a public dashboard using an undefined panelId, which may cause the backend to construct a request to /api/public/dashboards/{token}/panels/undefined/query and fetch data.
- Attacker request (example):
curl -X POST \
-H "Content-Type: application/json" \
https://grafana.example/api/public/dashboards/test-token/panels/undefined/query \
-d '{"targets":[{"refId":"A"}],"range":{"from":"now-1h","to":"now"},"maxDataPoints":1000}'
- Expected (vulnerable behavior): backend may perform a data fetch and respond with sensitive data or error details leaking information about datasets accessible to public dashboards.
2) Demonstrating the patched behavior:
- After the fix, requests to the undefined panelId path return an empty dataset without performing a backend fetch.
- Expected response (post-fix): data: [] (empty array), and no network fetch is triggered by the handler.
3) Optional existence check for NaN panelId (similar to undefined):
- curl -X POST https://grafana.example/api/public/dashboards/test-token/panels/NaN/query with a valid body
- Expected post-fix behavior: empty data with no backend fetch.
Notes:
- The PoC focuses on the path /panels/undefined/query (and analogous /panels/NaN/query) to illustrate the vulnerability surface that the fix covers.
- In a real scenario you would observe the difference in server behavior and data exposure before vs after applying the fix.
Commit Details
Author: SamareshSingh
Date: 2026-05-13 07:56 UTC
Message:
Public dashboards: skip variable refresh and broken /panels/undefined/query in v2 (#124516)
* Public dashboards: skip variable refresh and broken /panels/undefined/query in v2
* Public dashboards: add tests for v2 query variable refresh override and panelId guard
* CODEOWNERS: include publicDashboardQueryHandler test file via wildcard
* transformSaveModelSchemaV2ToScene: fix import order so @grafana/scenes precedes @grafana/schema
Triage Assessment
Vulnerability Type: Information Disclosure
Confidence: MEDIUM
Reasoning:
The changes introduce input validation and guards in public dashboard query handling (e.g., panelId null/NaN checks) and modify variable refresh behavior in public mode to never refresh. This prevents unintended data fetches and potential exposure via public dashboards, addressing security-related edge cases (information disclosure via public access).
Verification Assessment
Vulnerability Type: Information Disclosure via Public Dashboards
Confidence: MEDIUM
Affected Versions: < 12.4.0
Code Diff
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 625c305e32ac..31327aa4d3b2 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -683,7 +683,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/utils/megaMenuOpen.ts @grafana/grafana-frontend-navigation
/packages/grafana-runtime/src/utils/migrationHandler* @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
/packages/grafana-runtime/src/utils/plugin.ts @grafana/grafana-frontend-platform
-/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.ts @grafana/grafana-operator-experience-squad
+/packages/grafana-runtime/src/utils/publicDashboardQueryHandler* @grafana/grafana-operator-experience-squad
/packages/grafana-runtime/src/utils/qscheck* @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/queryResponse* @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/rbac.ts @grafana/identity-access-team
diff --git a/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.test.ts b/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.test.ts
new file mode 100644
index 000000000000..888e85debf43
--- /dev/null
+++ b/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.test.ts
@@ -0,0 +1,71 @@
+import { lastValueFrom, of } from 'rxjs';
+
+import { type DataQueryRequest, getDefaultTimeRange } from '@grafana/data';
+
+import { config } from '../config';
+import { type BackendSrv, type BackendSrvRequest, type FetchResponse } from '../services';
+
+import { publicDashboardQueryHandler } from './publicDashboardQueryHandler';
+
+const fetchMock = jest.fn();
+const backendSrv = {
+ fetch: (options: BackendSrvRequest) => of(fetchMock(options) as FetchResponse),
+} as unknown as BackendSrv;
+
+jest.mock('../services/backendSrv', () => ({
+ ...jest.requireActual('../services/backendSrv'),
+ getBackendSrv: () => backendSrv,
+}));
+
+function makeRequest(overrides: Partial<DataQueryRequest> = {}): DataQueryRequest {
+ return {
+ targets: [{ refId: 'A' }],
+ range: getDefaultTimeRange(),
+ requestId: 'req-1',
+ intervalMs: 5000,
+ maxDataPoints: 100,
+ ...overrides,
+ } as DataQueryRequest;
+}
+
+describe('publicDashboardQueryHandler', () => {
+ const originalToken = config.publicDashboardAccessToken;
+
+ beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockReturnValue({ data: { results: {} }, status: 200 });
+ config.publicDashboardAccessToken = 'test-token';
+ });
+
+ afterEach(() => {
+ config.publicDashboardAccessToken = originalToken;
+ });
+
+ it('returns an empty response and does not call fetch when panelId is undefined', async () => {
+ const response = await lastValueFrom(publicDashboardQueryHandler(makeRequest({ panelId: undefined })));
+
+ expect(response.data).toEqual([]);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('returns an empty response and does not call fetch when panelId is NaN', async () => {
+ const response = await lastValueFrom(publicDashboardQueryHandler(makeRequest({ panelId: NaN })));
+
+ expect(response.data).toEqual([]);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('calls fetch with the panelId-bearing URL when panelId is valid', async () => {
+ await lastValueFrom(publicDashboardQueryHandler(makeRequest({ panelId: 42 })));
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0][0].url).toBe('/api/public/dashboards/test-token/panels/42/query');
+ });
+
+ it('returns an empty response when targets is empty, irrespective of panelId', async () => {
+ const response = await lastValueFrom(publicDashboardQueryHandler(makeRequest({ panelId: 42, targets: [] })));
+
+ expect(response.data).toEqual([]);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.ts b/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.ts
index 0f43f236b5c1..a81fb8508fb0 100644
--- a/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.ts
+++ b/packages/grafana-runtime/src/utils/publicDashboardQueryHandler.ts
@@ -21,6 +21,10 @@ export function publicDashboardQueryHandler(request: DataQueryRequest<DataQuery>
return of({ data: [] });
}
+ if (panelId == null || Number.isNaN(panelId)) {
+ return of({ data: [] });
+ }
+
const body = {
intervalMs,
maxDataPoints,
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
index 1b93ca546875..730e093f81c0 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
@@ -16,6 +16,7 @@ import {
type SceneGridItem,
SwitchVariable,
} from '@grafana/scenes';
+import { VariableRefresh } from '@grafana/schema';
import {
type AdhocVariableKind,
type ConstantVariableKind,
@@ -382,6 +383,42 @@ describe('transformSaveModelSchemaV2ToScene', () => {
});
});
+ describe('query variables in public dashboard mode', () => {
+ it('forces refresh to never when publicDashboardAccessToken is set, regardless of spec refresh', () => {
+ const originalToken = config.publicDashboardAccessToken;
+ config.publicDashboardAccessToken = 'test-public-token';
+ try {
+ const dashboard = cloneDeep(defaultDashboard);
+ const queryVar = dashboard.spec.variables.find((v) => v.kind === 'QueryVariable') as QueryVariableKind;
+ queryVar.spec.refresh = 'onDashboardLoad';
+
+ const scene = transformSaveModelSchemaV2ToScene(dashboard);
+ const variable = scene.state.$variables?.getByName('queryVar') as QueryVariable;
+
+ expect(variable.state.refresh).toBe(VariableRefresh.never);
+ } finally {
+ config.publicDashboardAccessToken = originalToken;
+ }
+ });
+
+ it('honors the spec refresh value when not in public dashboard mode', () => {
+ const originalToken = config.publicDashboardAccessToken;
+ config.publicDashboardAccessToken = '';
+ try {
+ const dashboard = cloneDeep(defaultDashboard);
+ const queryVar = dashboard.spec.variables.find((v) => v.kind === 'QueryVariable') as QueryVariableKind;
+ queryVar.spec.refresh = 'onDashboardLoad';
+
+ const scene = transformSaveModelSchemaV2ToScene(dashboard);
+ const variable = scene.state.$variables?.getByName('queryVar') as QueryVariable;
+
+ expect(variable.state.refresh).toBe(VariableRefresh.onDashboardLoad);
+ } finally {
+ config.publicDashboardAccessToken = originalToken;
+ }
+ });
+ });
+
describe('adhoc variables', () => {
it('should convert empty defaultKeys array to undefined', () => {
const dashboard = cloneDeep(defaultDashboard);
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
index ac2429f9c91b..62378be7e0e3 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
@@ -20,6 +20,7 @@ import {
SwitchVariable,
TextBoxVariable,
} from '@grafana/scenes';
+import { VariableRefresh } from '@grafana/schema';
import {
type AdhocVariableKind,
type ConstantVariableKind,
@@ -409,6 +410,9 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
valuesFormat: variable.spec.valuesFormat || 'csv',
});
} else if (variable.kind === defaultQueryVariableKind().kind) {
+ const refresh = config.publicDashboardAccessToken
+ ? VariableRefresh.never
+ : transformVariableRefreshToEnumV1(variable.spec.refresh);
return new QueryVariable({
...commonProperties,
value: variable.spec.current?.value ?? '',
@@ -416,7 +420,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
query: getDataQueryForVariable(variable),
datasource: getRuntimeVariableDataSource(variable),
sort: transformSortVariableToEnumV1(variable.spec.sort),
- refresh: transformVariableRefreshToEnumV1(variable.spec.refresh),
+ refresh,
regex: variable.spec.regex,
regexApplyTo: variable.spec.regexApplyTo,
allValue: variable.spec.allValue || undefined,