Privilege escalation / Access control

MEDIUM
grafana/grafana
Commit: dcbebb74c760
Affected: <=12.4.0
2026-06-17 11:44 UTC

Description

The commit adds a fine-grained, read-only Administration permission for GitHub tokens used by the provisioning flow. Previously, tokens with Administration rights could be used to perform privileged actions (e.g., modifying repository settings or branch protections) during provisioning. By explicitly adding an Administration: Read-only permission for GitHub tokens (and gating its use to only the GitHub provider), the fix reduces the risk of privilege escalation when Grafana provisions dashboards by validating and/or modifying repository content. The change is complemented by UI/test updates to reflect and enforce the new permission model. The flamegraph change and some UI tweaks are non-security-impacting, but the primary security improvement is the token permission hardening.

Proof of Concept

Proof-of-concept (high level): Prereqs: a Grafana provisioning integration configured to use a GitHub token with Administrative permissions; a GitHub repository with a protected branch; a provision flow that writes provisioning artifacts to the repo via GitHub API. Steps: 1) Create or select a test GitHub repo with a protected branch (e.g., main) and ensure the provisioning path would write a file to a path on that branch. 2) Create a GitHub fine-grained token with Administration: Full rights (admin scope) for that repository. Ensure this token is used by Grafana provisioning (pre-fix behavior). 3) Trigger provisioning that results in a write to the repository (e.g., upserting a provisioned dashboard manifest) on the protected branch via the GitHub Contents API (PUT /repos/{owner}/{repo}/contents/{path}). 4) Observe that the write succeeds, thereby demonstrating that a token with Admin permissions can bypass protections or modify repository state during provisioning. Note: The actual fix is to require/derive the token permissions as Administration: Read-only for the GitHub provider so that the provisioning flow can validate branch protections without granting write privileges. Do not perform this on real repos; use a dedicated test repo and tokens. Minimal PoC code snippet (curl): curl -X PUT -H "Authorization: Bearer ADMIN_TOKEN" \ -H "Accept: application/vnd.github+json" \ -d '{"message":"Provision: test write","content":"BASE64_ENCODED_CONTENT","branch":"protected-branch"}' \ https://api.github.com/repos/OWNER/REPO/contents/path/to/file.json Expected result: 200 or 201 response indicating a file write on the protected branch; this demonstrates how an admin token could be abused to alter repository state during provisioning. Replace placeholders with real test values and execute only against a test repository.

Commit Details

Author: Alex Khomenko

Date: 2026-06-17 11:00 UTC

Message:

Provisioning: Fix Git Sync save feedback, token permissions, and actions alignment (#126672) * Provisioning: Show in-progress state on Save while saving a provisioned dashboard The Save button keyed only on request.isLoading, which flips true after react-hook-form finishes its async validators and dispatches the write. For a new dashboard those validators (name search + git file-exists check) are slow, so the button stayed enabled reading "Save" during the lag. Also gate disabled/label on isSubmitting and isValidating so the button is disabled and shows "Saving..." from click through validation to completion, for both new and existing saves. * Provisioning: Add Administration read-only to GitHub token permissions GitHub fine-grained tokens need Administration: Read-only so the configured branch's protection rules can be validated when users may push to it directly. Scoped to the github case only; gitlab/bitbucket are unchanged. * align items * Flamegraph: Make useDevicePixelRatio module-local to satisfy knip The hook is only used within rendering.ts and isn't imported by any test or re-exported, so the export was flagged as unused by knip. Drop the export keyword and the stale 'exported for testing' note.

Triage Assessment

Vulnerability Type: Privilege escalation / Access control

Confidence: MEDIUM

Reasoning:

The commit introduces Administration: Read-only permission for GitHub tokens to allow validation of branch protection rules when users push to a protected branch. This is a security hardening to prevent unintended privilege elevation and to enforce correct permissions during provisioning. Additionally, there are UI/UX fixes around save feedback, which are not security-related, but the token permissions change constitutes a security improvement. The diff also includes tests around token permissions, supporting the security intent.

Verification Assessment

Vulnerability Type: Privilege escalation / Access control

Confidence: MEDIUM

Affected Versions: <=12.4.0

Code Diff

diff --git a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts index 3c3b2605f0c21..e4c40d42b1e1b 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts +++ b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts @@ -386,10 +386,8 @@ function useColorFunction( * triggers: * - Moving the browser window between a HiDPI and a standard display (matchMedia) * - Browser zoom changes (visualViewport resize) - * - * Exported for testing — do not use directly outside this module. */ -export function useDevicePixelRatio() { +function useDevicePixelRatio() { const [devicePixelRatio, setDevicePixelRatio] = useState(window.devicePixelRatio); useEffect(() => { diff --git a/public/app/features/provisioning/Repository/RepositoryActions.tsx b/public/app/features/provisioning/Repository/RepositoryActions.tsx index a0881050e78ab..34fb87ffe6f5b 100644 --- a/public/app/features/provisioning/Repository/RepositoryActions.tsx +++ b/public/app/features/provisioning/Repository/RepositoryActions.tsx @@ -29,7 +29,7 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) { const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows); return ( - <Stack wrap="wrap"> + <Stack wrap="wrap" alignItems="center"> {isReadOnlyRepo && <ReadOnlyBadge repoType={repoType} />} <StatusBadge repo={repository} displayOnly /> {repoHref && ( diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx new file mode 100644 index 0000000000000..143b0f968b1aa --- /dev/null +++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from 'test/test-utils'; + +import { TokenPermissionsInfo } from './TokenPermissionsInfo'; + +describe('TokenPermissionsInfo', () => { + it('lists the Administration: Read-only permission for GitHub', async () => { + render(<TokenPermissionsInfo type="github" />); + + const row = await screen.findByText(/Administration/); + expect(row).toHaveTextContent('Administration: Read-only'); + }); + + it('does not list the Administration permission for non-GitHub providers', async () => { + render(<TokenPermissionsInfo type="gitlab" />); + + // Wait for the GitLab list to render before asserting the negative. + expect(await screen.findByText(/Repository/)).toBeInTheDocument(); + expect(screen.queryByText(/Administration/)).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx index ae1cf890ab549..49be341252f38 100644 --- a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx +++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx @@ -74,6 +74,7 @@ function getPermissionsForProvider(type: InstructionAvailability): Permission[] { name: 'Metadata', access: 'Read only' }, { name: 'Pull requests', access: 'Read and write' }, { name: 'Webhooks', access: 'Read and write' }, + { name: 'Administration', access: 'Read-only' }, ]; case 'gitlab': return [ diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index 44482d7d0973e..311eb15bc1d20 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -1,4 +1,4 @@ -import { HttpResponse, http } from 'msw'; +import { HttpResponse, delay, http } from 'msw'; import { act, render, screen, waitFor } from 'test/test-utils'; import { type Dashboard } from '@grafana/schema'; @@ -375,6 +375,161 @@ describe('SaveProvisionedDashboardForm', () => { expect(request.body).toEqual(updatedDashboard); }); + it('shows the in-progress state on Save while new dashboard validation is pending', async () => { + server.use( + http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => { + const url = new URL(request.url); + capturedRequest = { url, body: await request.json() }; + return HttpResponse.json({ + resource: { upsert: { metadata: { name: 'new-dashboard' }, spec: { title: 'New Dashboard' } } }, + }); + }) + ); + + // Hold title validation open so the submit stays in its validation phase. + let resolveValidation!: (value: unknown) => void; + const validationPromise = new Promise((resolve) => { + resolveValidation = resolve; + }); + (validationSrv.validateNewDashboardName as jest.Mock).mockReturnValue(validationPromise); + + const newDashboard = { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { generateName: 'p', name: undefined }, + spec: { title: 'New Dashboard', panels: [], schemaVersion: 36 }, + }; + + const { user, props } = setup(); + props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard); + + const titleInput = screen.getByRole('textbox', { name: /title/i }); + await user.clear(titleInput); + await user.type(titleInput, 'New Dashboard'); + + const filenameInput = screen.getByRole('textbox', { name: /filename/i }); + await user.clear(filenameInput); + await user.type(filenameInput, 'custom-filename.json'); + + await user.click(screen.getByRole('button', { name: /save/i })); + + // The button reflects the in-progress submit during validation, before the create POST fires. + const savingButton = await screen.findByRole('button', { name: /saving/i }); + expect(savingButton).toBeDisabled(); + expect(capturedRequest).toBeNull(); + + // Completing validation lets the create POST go out. + await act(async () => { + resolveValidation(true); + }); + await waitFor(() => expect(capturedRequest).not.toBeNull()); + }); + + it('re-enables Save and skips the write when new dashboard validation fails', async () => { + server.use( + http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => { + const url = new URL(request.url); + capturedRequest = { url, body: await request.json() }; + return HttpResponse.json({ + resource: { upsert: { metadata: { name: 'new-dashboard' }, spec: { title: 'New Dashboard' } } }, + }); + }) + ); + + (validationSrv.validateNewDashboardName as jest.Mock).mockRejectedValue( + new Error('A dashboard or a folder with the same name already exists') + ); + + const { user, props } = setup(); + props.dashboard.getSaveResource = jest.fn().mockReturnValue({ + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { generateName: 'p' }, + spec: { title: 'New Dashboard', panels: [], schemaVersion: 36 }, + }); + + const titleInput = screen.getByRole('textbox', { name: /title/i }); + await user.clear(titleInput); + await user.type(titleInput, 'New Dashboard'); + + await user.click(screen.getByRole('button', { name: /save/i })); + + // Failed validation re-enables the button and never fires the create POST. + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled(); + }); + expect(capturedRequest).toBeNull(); + }); + + it('shows the in-progress state on Save while an existing dashboard is being written', async () => { + server.use( + http.put(`${BASE}/repositories/:name/files/*`, async ({ request }) => { + // Keep the write in flight so the in-progress state is observable. + await delay(50); + const url = new URL(request.url); + capturedRequest = { url, body: await request.json() }; + return HttpResponse.json({ + resource: { upsert: { metadata: { name: 'test-dashboard' }, spec: { title: 'Test Dashboard' } } }, + }); + }) + ); + + const updatedDashboard = { + apiVersion: 'dashboard.grafana.app/vXyz', + metadata: { + name: 'test-dashboard', + annotations: { + [AnnoKeyFolder]: 'folder-uid', + [AnnoKeySourcePath]: 'path/to/file.json', + }, + }, + spec: { title: 'Test Dashboard', description: 'Test Description' }, + }; + const { user } = setup({ + isNew: false, + dashboard: { + state: { + meta: { + folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder], + slug: 'test-dashboard', + uid: updatedDashboard.metadata.name, + k8s: updatedDashboard.metadata, + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: true, + }, + useState: () => ({ + meta: { + folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder], + slug: 'test-dashboard', + uid: updatedDashboard.metadata.name, + k8s: updatedDashboard.metadata, + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: true, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveResource: jest.fn().mockReturnValue(updatedDashboard), + setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), + } as unknown as DashboardScene, + }); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(commentInput); + await user.type(commentInput, 'Update dashboard'); + await user.click(screen.getByRole('button', { name: /save/i })); + + // The in-progress state shows for edits too, while the write is in flight. + const savingButton = await screen.findByRole('button', { name: /saving/i }); + expect(savingButton).toBeDisabled(); + + await waitFor(() => expect(capturedRequest).not.toBeNull()); + }); + it('should rename file when path changes on existing dashboard', async () => { server.use( http.post(`${BASE}/repositories/:name/files/*`, async ({ request }) => { diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index f86a58d41065a..7d33316317b25 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -68,7 +68,7 @@ export function SaveProvisionedDashboardForm({ register, setValue, getValues, - formState: { dirtyFields }, + formState: { dirtyFields, isSubmitting, isValidating }, } = methods; const path = watch('path'); @@ -371,8 +371,12 @@ export function SaveProvisionedDashboardForm({ <Button variant="secondary" onClick={drawer.onClose} fill="outline"> <Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans> </Button> - <Button variant="primary" type="submit" disabled={request.isLoading || readOnly || !isDirtyState}> - {request.isLoading + <Button + variant="primary" + type="submit" + disabled={request.isLoading || readOnly || !isDirtyState || isSubmitting || isValidating} + > + {request.isLoading || isSubmitting || isValidating ? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...') : t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')} </Button>
← Back to Alerts View on GitHub →