Information disclosure / Information leakage

HIGH
grafana/grafana
Commit: 05c1361a73e4
Affected: <=12.4.0
2026-04-28 10:43 UTC

Description

Grafana 12.4.0 and earlier versions could disclose internal identity references in the Recently Deleted dashboards UI. Prior to this fix, the UI rendered raw identity UIDs (e.g.,

Proof of Concept

Proof-of-concept (safe, test-environment depiction): Background: The UI shows a Deleted by column that relies on an IAM display map. If the IAM lookup has no entry for a deleted identity (deleted account) or if the lookup batch fails, the previous behavior could leak the raw UID (e.g., user:5) into the UI. This enables a user to infer internal UID formats and potentially enumerate identities via the Recently Deleted view or related search paths. Pseudocode demonstration (safe test environment, no real Grafana instance required): // Before fix (vulnerability): the display returns the raw UID when display mapping lacks an entry function formatDeletedByDisplayValueOld(uid, displayMap) { if (displayMap.has(uid)) { return displayMap.get(uid); } // leakage: raw UID is shown return uid; } const displayMap = new Map(); // empty map simulates missing IAM entry console.log(formatDeletedByDisplayValueOld('user:5', displayMap)); // outputs: 'user:5' (leak) // After fix (sentinel-based): use sentinels to mask leaks const DELETED_BY_REMOVED = 'DELETED_BY_REMOVED'; const DELETED_BY_UNKNOWN = 'DELETED_BY_UNKNOWN'; function formatDeletedByDisplayValueNew(value) { if (value === DELETED_BY_REMOVED) { return 'Deleted account'; } if (value === DELETED_BY_UNKNOWN) { return 'Unknown account'; } // value is a real display string/value from IAM return value; } // Simulated lookup results for a user:5 that is deleted and has no IAM entry console.log(formatDeletedByDisplayValueNew(DELETED_BY_REMOVED)); // 'Deleted account' console.log(formatDeletedByDisplayValueNew(DELETED_BY_UNKNOWN)); // 'Unknown account' // If a real display value is present, it is shown as usual console.log(formatDeletedByDisplayValueNew('Alice')); // 'Alice' Recommendation: In production, ensure that Deleted by values are always transformed via the sentinel-based path (as implemented in the patch) so no raw UIDs leak to the UI.

Commit Details

Author: Alex Khomenko

Date: 2026-04-28 09:46 UTC

Message:

Restore dashboards: Add Deleted by column and sort to Recently Deleted view (#123364) * Dashboards: Add Deleted by column and sort to Recently Deleted view * Dashboards: Test resolveDeletedByDisplayMap helper in trash cache * Dashboards: Resolve typed numeric user annotations in trash display map * Dashboards: Avoid leaking RTK Query subscriptions in trash display lookup * Dashboards: Surface IAM display lookup errors and stabilize RTKQ cache key * Dashboards: Show "Deleted account" when the deleter was removed Previously, when a user (or service account, API key, etc.) who deleted a dashboard was themselves deleted, the trash page's "Deleted by" column rendered the raw UID annotation (e.g. `user:5`) because the IAM display endpoint has no entry for deleted identities. Introduce two sentinel values in the deletedBy display map: - DELETED_BY_REMOVED: IAM lookup succeeded for this UID but returned no entry (account deleted) - DELETED_BY_UNKNOWN: IAM batch containing this UID failed (network/timeout/server error) The trash table now renders "Deleted account" and "Unknown account" respectively via formatDeletedByDisplayValue(). The deletedby sort treats both sentinels as missing so rows sort to the end in both directions. Raw UIDs no longer leak to the UI. * Dashboards: Chunk IAM display lookup to avoid oversize URLs resolveDeletedByDisplayMap previously dispatched a single `getDisplayMapping` request with up to 1000 `?key=` query parameters, producing ~27 KB query strings that exceeded nginx's default 8 KB header buffer for large trash pages. Split UID lookup into batches of IAM_DISPLAY_BATCH_SIZE (200) keys dispatched in parallel via Promise.allSettled so a single failing batch does not blank out every row. The resolver now owns sentinel ownership: on success it returns a map whose value for every requested UID is one of - a real display name (IAM hit) - DELETED_BY_REMOVED (successful batch, no entry for this UID) - DELETED_BY_UNKNOWN (batch for this UID failed) Subscriptions are built inside `try` so `finally` always cleans up whatever was created before any synchronous throw. Error messages are routed through getMessageFromError for consistent logging. * Dashboards: Strengthen deletedby sort tests with Unicode and sentinel coverage The previous deletedby sort tests covered only ASCII names and a simple "ascending reverses descending" invariant, which is too weak: it would pass even if the sort function ignored Intl.Collator rules. Add tests that compute expected order from the same `new Intl.Collator()` the production code uses, then assert the sort output matches, for: - Diacritic names (e.g. Ëmïlÿ, Öskar) - Japanese names - Arabic names Also add a test that verifies both DELETED_BY_REMOVED and DELETED_BY_UNKNOWN sentinel values sort to the end of the result in both ascending and descending directions. * add class-level cache, remove unknown user * remove duplicate cache * simplify * fix tests * Search: fall back to DELETED_BY_UNKNOWN instead of raw UID in resourceToSearchResult resourceToSearchResult is exported and can be called with an incomplete or undefined display map. Previously it fell back to the raw annotated UID (e.g. "user:ffkf6bh837z0ga") which would leak into the UI. Now it falls back to DELETED_BY_UNKNOWN so the UI shows a placeholder with a warning icon instead of a raw identifier. * Search: use Trans component instead of t() for deleted-by-unknown label The label contains an embedded <Icon/> component, so Trans is the correct i18n primitive — it preserves the JSX tree as indexed placeholders in the translation string.

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: HIGH

Reasoning:

The change introduces sentinel values for the Deleted by display and ensures UI does not leak raw UIDs (e.g., user:5) when identities are deleted or when lookups fail. This mitigates information disclosure via the Recently Deleted views and related search/display paths. It also batches IAM lookups to avoid overly large requests, but the security impact is primarily preventing leakage of sensitive identifiers.

Verification Assessment

Vulnerability Type: Information disclosure / Information leakage

Confidence: HIGH

Affected Versions: <=12.4.0

Code Diff

diff --git a/public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.ts b/public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.ts index 1d4bfd1f84cfb..177aee5aa2896 100644 --- a/public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.ts +++ b/public/app/features/browse-dashboards/api/useRecentlyDeletedStateManager.ts @@ -88,6 +88,14 @@ export class TrashStateManager extends SearchStateManager { label: t('browse-dashboards.trash-state-manager.label.deleted-newest', 'Deleted (newest first)'), value: 'deleted-desc', }, + { + label: t('browse-dashboards.trash-state-manager.label.deleted-by-az', 'Deleted by (A–Z)'), + value: 'deletedby-asc', + }, + { + label: t('browse-dashboards.trash-state-manager.label.deleted-by-za', 'Deleted by (Z–A)'), + value: 'deletedby-desc', + }, ]); }; } diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 3f479fb047f6b..01b36ebfa4f94 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -20,7 +20,7 @@ import { PluginIconName } from 'app/features/plugins/admin/types'; import { ShowModalReactEvent } from 'app/types/events'; import { type QueryResponse, type SearchResultMeta } from '../../service/types'; -import { getIconForKind } from '../../service/utils'; +import { DELETED_BY_UNKNOWN, formatDeletedByDisplayValue, getIconForKind } from '../../service/utils'; import { type SelectionChecker, type SelectionToggle } from '../selection'; import { ExplainScorePopup } from './ExplainScorePopup'; @@ -29,6 +29,7 @@ import { type TableColumn } from './SearchResultsTable'; const TYPE_COLUMN_WIDTH = 175; const DURATION_COLUMN_WIDTH = 200; const DATASOURCE_COLUMN_WIDTH = 200; +const DELETED_BY_COLUMN_WIDTH = 200; export const generateColumns = ( response: QueryResponse, @@ -160,6 +161,46 @@ export const generateColumns = ( availableWidth -= width; } + const deletedByField = access.deletedBy; + if (deletedByField && hasValue(deletedByField)) { + width = DELETED_BY_COLUMN_WIDTH; + columns.push({ + id: `column-deleted-by`, + field: deletedByField, + Header: t('search.results-table.deleted-by-header', 'Deleted by'), + width, + Cell: (p) => { + const rawValue = deletedByField.values[p.row.index]; + const { key, ...cellProps } = p.cellProps; + return ( + <div key={key} {...cellProps} className={styles.cell}> + {!response.isItemLoaded(p.row.index) ? ( + <Skeleton width={150} /> + ) : rawValue === DELETED_BY_UNKNOWN ? ( + <Tooltip + content={t( + 'search.results-table.deleted-by-unknown-tooltip', + 'Failed to look up the account that deleted this dashboard' + )} + > + <Text variant="body" truncate> + <Trans i18nKey="search.results-table.deleted-by-unknown-short"> + <Icon name="exclamation-triangle" /> Unknown + </Trans> + </Text> + </Tooltip> + ) : ( + <Text variant="body" truncate> + {formatDeletedByDisplayValue(rawValue, t)} + </Text> + )} + </div> + ); + }, + }); + availableWidth -= width; + } + // Show datasources if we have any if (access.ds_uid && onDatasourceChange) { width = Math.min(availableWidth / 2.5, DATASOURCE_COLUMN_WIDTH); diff --git a/public/app/features/search/service/deletedDashboardsCache.test.ts b/public/app/features/search/service/deletedDashboardsCache.test.ts new file mode 100644 index 0000000000000..a24118dab2f11 --- /dev/null +++ b/public/app/features/search/service/deletedDashboardsCache.test.ts @@ -0,0 +1,386 @@ +import { iamAPIv0alpha1, type Display, type DisplayList } from 'app/api/clients/iam/v0alpha1'; +import { AnnoKeyUpdatedBy } from 'app/features/apiserver/types'; +import { dispatch } from 'app/types/store'; + +import { deletedDashboardsCache, resolveDeletedByDisplayMap } from './deletedDashboardsCache'; +import { DELETED_BY_REMOVED, DELETED_BY_UNKNOWN } from './utils'; + +jest.mock('app/api/clients/iam/v0alpha1', () => ({ + iamAPIv0alpha1: { + endpoints: { + getDisplayMapping: { + initiate: jest.fn(), + }, + }, + }, +})); + +jest.mock('app/types/store', () => ({ + ...jest.requireActual('app/types/store'), + dispatch: jest.fn(), +})); + +jest.mock('app/features/dashboard/api/dashboard_api', () => ({ + getDashboardAPI: jest.fn(), +})); + +jest.mock('app/features/apiserver/guards', () => ({ + isResourceList: jest.fn(() => true), +})); +const mockInitiate = iamAPIv0alpha1.endpoints.getDisplayMapping.initiate as unknown as jest.Mock; +const mockDispatch = dispatch as unknown as jest.Mock; + +function makeDisplayList(display: Display[]): DisplayList { + return { + display, + keys: display.map((d) => `${d.identity.type}:${d.identity.name}`), + metadata: {}, + }; +} + +type MockResult = { data?: DisplayList; error?: unknown }; + +// The dispatched RTK Query thunk returns a `QueryActionCreatorResult` — a thenable. +// Production awaits the thenables via `Promise.allSettled`, so tests must return a +// shape that satisfies `PromiseLike`. +function mockSubscription(result: MockResult | Error): PromiseLike<MockResult> { + return { + then(onFulfilled, onRejected) { + if (result instanceof Error) { + return Promise.reject(result).then(onFulfilled, onRejected); + } + return Promise.resolve(result).then(onFulfilled, onRejected); + }, + }; +} + +describe('resolveDeletedByDisplayMap', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInitiate.mockReturnValue('initiate-thunk'); + }); + + it('returns an empty map and does not dispatch when given no UIDs', async () => { + const map = await resolveDeletedByDisplayMap(new Set(), new Map()); + + expect(map.size).toBe(0); + expect(mockInitiate).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('dispatches getDisplayMapping with sorted UIDs and subscribe:false', async () => { + mockDispatch.mockReturnValue(mockSubscription({ data: makeDisplayList([]) })); + + await resolveDeletedByDisplayMap(new Set(['user:bob', 'user:alice']), new Map()); + + expect(mockInitiate).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith('initiate-thunk'); + const [keyArg, optionsArg] = mockInitiate.mock.calls[0] as [{ key: string[] }, { subscribe: boolean }]; + // Keys are sorted before dispatch so equivalent UID sets produce stable RTKQ cache keys. + expect(keyArg.key).toEqual(['user:alice', 'user:bob']); + expect(optionsArg).toEqual({ subscribe: false }); + }); + + it('skips UIDs already resolved in the cache', async () => { + const cache = new Map<string, string>([['user:alice', 'Alice']]); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), cache); + + expect(map.get('user:alice')).toBe('Alice'); + expect(mockInitiate).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('dispatches only for the cache-miss subset', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([{ identity: { type: 'user', name: 'bob' }, displayName: 'Bob' }]), + }) + ); + const cache = new Map<string, string>([['user:alice', 'Alice']]); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice', 'user:bob']), cache); + + expect(map.get('user:alice')).toBe('Alice'); + expect(map.get('user:bob')).toBe('Bob'); + const [keyArg] = mockInitiate.mock.calls[0] as [{ key: string[] }]; + expect(keyArg.key).toEqual(['user:bob']); + }); + + it('re-fetches UIDs whose previous lookup yielded DELETED_BY_UNKNOWN', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([{ identity: { type: 'user', name: 'alice' }, displayName: 'Alice' }]), + }) + ); + const cache = new Map<string, string>([['user:alice', DELETED_BY_UNKNOWN]]); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), cache); + + expect(map.get('user:alice')).toBe('Alice'); + expect(mockInitiate).toHaveBeenCalledTimes(1); + expect(cache.get('user:alice')).toBe('Alice'); + }); + + it('does not re-fetch UIDs cached as DELETED_BY_REMOVED (terminal state)', async () => { + const cache = new Map<string, string>([['user:alice', DELETED_BY_REMOVED]]); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), cache); + + expect(map.get('user:alice')).toBe(DELETED_BY_REMOVED); + expect(mockInitiate).not.toHaveBeenCalled(); + }); + + it('writes resolved entries back to the shared cache', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([{ identity: { type: 'user', name: 'alice' }, displayName: 'Alice' }]), + }) + ); + const cache = new Map<string, string>(); + + await resolveDeletedByDisplayMap(new Set(['user:alice']), cache); + + expect(cache.get('user:alice')).toBe('Alice'); + }); + + it('logs and marks UIDs as unknown when RTK Query resolves with an error result', async () => { + // RTK Query query thunks are `SafePromise`s — on request failure they resolve with + // an `{ error, data: undefined }` shape rather than rejecting. Production routes the error + // through `getMessageFromError`, which falls back to `JSON.stringify` for plain objects. + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockDispatch.mockReturnValue(mockSubscription({ error: { status: 500, data: 'upstream failure' } })); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), new Map()); + + expect(map.get('user:alice')).toBe(DELETED_BY_UNKNOWN); + expect(consoleError).toHaveBeenCalledWith( + 'Failed to resolve deleted dashboard user displays:', + expect.stringContaining('500') + ); + consoleError.mockRestore(); + }); + + it('builds a map keyed by identity `type:name`', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ data: makeDisplayList([{ identity: { type: 'user', name: 'alice' }, displayName: 'Alice' }]) }) + ); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), new Map()); + + expect(map.get('user:alice')).toBe('Alice'); + }); + + it('additionally caches by String(internalId) when provided', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([{ identity: { type: 'user', name: 'alice' }, displayName: 'Alice', internalId: 42 }]), + }) + ); + const cache = new Map<string, string>(); + + await resolveDeletedByDisplayMap(new Set(['user:alice']), cache); + + expect(cache.get('user:alice')).toBe('Alice'); + expect(cache.get('42')).toBe('Alice'); + expect(cache.get('user:42')).toBe('Alice'); + }); + + it('aliases typed numeric annotations to the canonical entry via `<type>:<internalId>`', async () => { + // Simulates the case where the annotation is `user:1` but the server + // canonicalizes to `user:u000000001` plus `internalId: 1`. + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([ + { identity: { type: 'user', name: 'u000000001' }, displayName: 'Alice', internalId: 1 }, + ]), + }) + ); + const cache = new Map<string, string>(); + + const map = await resolveDeletedByDisplayMap(new Set(['user:1']), cache); + + expect(map.get('user:1')).toBe('Alice'); + expect(cache.get('user:u000000001')).toBe('Alice'); + expect(cache.get('1')).toBe('Alice'); + }); + + it('preserves Unicode display names verbatim', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ + data: makeDisplayList([ + { identity: { type: 'user', name: 'tanaka' }, displayName: '田中太郎' }, + { identity: { type: 'user', name: 'mohamed' }, displayName: 'محمد العربي' }, + ]), + }) + ); + + const map = await resolveDeletedByDisplayMap(new Set(['user:tanaka', 'user:mohamed']), new Map()); + + expect(map.get('user:tanaka')).toBe('田中太郎'); + expect(map.get('user:mohamed')).toBe('محمد العربي'); + }); + + it('marks UIDs the server could not resolve with DELETED_BY_REMOVED', async () => { + mockDispatch.mockReturnValue( + mockSubscription({ data: makeDisplayList([{ identity: { type: 'user', name: 'alice' }, displayName: 'Alice' }]) }) + ); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice', 'user:missing']), new Map()); + + expect(map.get('user:alice')).toBe('Alice'); + // Successful batch, no IAM entry for user:missing — account was deleted. + expect(map.get('user:missing')).toBe(DELETED_BY_REMOVED); + }); + + it('marks requested UIDs as DELETED_BY_UNKNOWN when the dispatch resolves with no data', async () => { + mockDispatch.mockReturnValue(mockSubscription({ data: undefined })); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), new Map()); + + // `data === undefined` is treated as batch failure so the UI renders a consistent placeholder + // rather than a raw UID. + expect(map.get('user:alice')).toBe(DELETED_BY_UNKNOWN); + }); + + it('marks requested UIDs as DELETED_BY_UNKNOWN and swallows errors when the dispatch rejects', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockDispatch.mockReturnValue(mockSubscription(new Error('boom'))); + + const map = await resolveDeletedByDisplayMap(new Set(['user:alice']), new Map()); + + expect(map.get('user:alice')).toBe(DELETED_BY_UNKNOWN); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('chunks large UID sets into batches of at most IAM_DISPLAY_BATCH_SIZE keys', async () => { + // Build 250 unique UIDs; at batch size 200 this must split into 2 dispatches. + const uids = new Set<string>(); + for (let i = 0; i < 250; i++) { + uids.add(`user:u${String(i).padStart(4, '0')}`); + } + mockDispatch.mockReturnValue(mockSubscription({ data: makeDisplayList([]) })); + + const map = await resolveDeletedByDisplayMap(uids, new Map()); + + expect(mockInitiate).toHaveBeenCalledTimes(2); + const firstBatch = (mockInitiate.mock.calls[0][0] as { key: string[] }).key; + const secondBatch = (mockInitiate.mock.calls[1][0] as { key: string[] }).key; + expect(firstBatch.length).toBeLessThanOrEqual(200); + expect(secondBatch ... [truncated]
← Back to Alerts View on GitHub →