XSS

MEDIUM
grafana/grafana
Commit: 967f17d19af0
Affected: < 12.4.0 (versions before this commit, i.e., 12.3.x and earlier)
2026-04-03 20:31 UTC

Description

The change adds a VariableDescriptionTooltip that sanitizes and validates links found in variable descriptions to prevent XSS via javascript: URLs and ensures external links have safe attributes. Before this fix, user-provided variable descriptions could include unsafe links such as javascript: URLs; these could be rendered into the dashboard UI and executed when clicked, leading to XSS. The new code only renders https/http URLs via getSafeExternalUrl and sanitizeUrl, and marks external links with target/_blank and rel attributes.

Proof of Concept

Proof-of-concept exploit (pre-fix): 1) Run Grafana 12.3.x (or earlier) and create a dashboard with a variable description containing a markdown link with a javascript URL: Description: 'See [examples](javascript:alert("XSS"))' 2) Render the dashboard and click the link. 3) The browser executes alert("XSS") due to the javascript: URL being rendered as an anchor (no sanitization in older versions). Mitigation (post-fix in 12.4.0): The VariableDescriptionTooltip sanitizes and blocks unsafe URLs. The same input will render as plain text or a sanitized link, and no script will execute.

Commit Details

Author: Levente Balogh

Date: 2026-03-26 03:57 UTC

Message:

Dashboards: Recognise links in variable description (#120259) * Dashboard scene: add variable description link tooltip * Dashboard scene: fix tooltip test mock typing * chore: fix prettier formatting * feat: add aria-label for the Icon

Triage Assessment

Vulnerability Type: XSS

Confidence: MEDIUM

Reasoning:

Introduces VariableDescriptionTooltip with explicit sanitization and validation of links found in variable descriptions, preventing unsafe javascript: URLs and ensuring external links use safe target/rel attributes. Tests cover that unsafe links are not rendered and that external links are sanitized, reducing XSS risk in descriptions.

Verification Assessment

Vulnerability Type: XSS

Confidence: MEDIUM

Affected Versions: < 12.4.0 (versions before this commit, i.e., 12.3.x and earlier)

Code Diff

diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 74622d5220132..7a293f227d6ec 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -18,6 +18,7 @@ import { useElementSelection, useStyles2 } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; import { AddVariableButton } from './VariableControlsAddButton'; +import { VariableDescriptionTooltip } from './VariableDescriptionTooltip'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); @@ -186,6 +187,14 @@ function VariableLabel({ } const labelOrName = state.label || state.name; + const controlsLayout = layout ?? 'horizontal'; + const descriptionSuffix = + state.description != null && state.description !== '' ? ( + <VariableDescriptionTooltip + description={state.description} + placement={controlsLayout === 'vertical' ? 'top' : 'bottom'} + /> + ) : undefined; return ( <ControlsLabel @@ -194,8 +203,9 @@ function VariableLabel({ onCancel={() => variable.onCancel?.()} label={labelOrName} error={state.error} - layout={layout ?? 'horizontal'} - description={state.description ?? undefined} + layout={controlsLayout} + description={undefined} + suffix={descriptionSuffix} className={className} /> ); diff --git a/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.test.tsx b/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.test.tsx new file mode 100644 index 0000000000000..85959d7ca313f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; + +import { Tooltip } from '@grafana/ui'; + +import { VariableDescriptionTooltip } from './VariableDescriptionTooltip'; + +function renderTooltipContent(content: React.ComponentProps<typeof Tooltip>['content']): React.ReactNode { + if (typeof content === 'function') { + return content({}); + } + + return content; +} + +jest.mock('@grafana/ui', () => { + const actual = jest.requireActual('@grafana/ui'); + + return { + ...actual, + Tooltip: ({ content, children, interactive, placement }: React.ComponentProps<typeof Tooltip>) => ( + <div data-testid="mock-tooltip" data-interactive={interactive ? 'true' : 'false'} data-placement={placement}> + <div data-testid="mock-tooltip-content">{renderTooltipContent(content)}</div> + {children} + </div> + ), + }; +}); + +describe('VariableDescriptionTooltip', () => { + it('renders markdown links as external links', () => { + render(<VariableDescriptionTooltip description={'Read [docs](https://grafana.com/docs).'} placement="bottom" />); + + const link = screen.getByRole('link', { name: 'docs' }); + expect(link).toHaveAttribute('href', 'https://grafana.com/docs'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders bare links as external links', () => { + render(<VariableDescriptionTooltip description={'Details: https://example.com/path?q=1.'} placement="bottom" />); + + const link = screen.getByRole('link', { name: 'https://example.com/path?q=1' }); + expect(link).toHaveAttribute('href', 'https://example.com/path?q=1'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('does not render unsafe links', () => { + render(<VariableDescriptionTooltip description={'Do not click [this](javascript:alert(1))'} placement="bottom" />); + + expect(screen.queryByRole('link', { name: 'this' })).not.toBeInTheDocument(); + expect(screen.getByText(/this/)).toBeInTheDocument(); + }); + + it('uses an interactive tooltip', () => { + render(<VariableDescriptionTooltip description={'Read [docs](https://grafana.com/docs).'} placement="top" />); + + expect(screen.getByTestId('mock-tooltip')).toHaveAttribute('data-interactive', 'true'); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.tsx b/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.tsx new file mode 100644 index 0000000000000..c577521f9cbbc --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VariableDescriptionTooltip.tsx @@ -0,0 +1,145 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { sanitizeUrl } from '@grafana/data/internal'; +import { t } from '@grafana/i18n'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; + +const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)\s]+)\)/g; +const BARE_LINK_REGEX = /https?:\/\/[^\s<>()]+/gi; +const TRAILING_PUNCTUATION_REGEX = /[),.;!?]+$/; +const SAFE_PROTOCOL_REGEX = /^https?:\/\//i; + +interface VariableDescriptionTooltipProps { + description: string; + placement: 'top' | 'bottom'; +} + +export function VariableDescriptionTooltip({ description, placement }: VariableDescriptionTooltipProps) { + const styles = useStyles2(getStyles); + + return ( + <Tooltip + content={<div className={styles.tooltipContent}>{renderDescriptionWithLinks(description, styles.link)}</div>} + placement={placement} + interactive + > + <Icon + name="info-circle" + size="sm" + className={styles.icon} + aria-label={t('dashboard.variable.description-tooltip', 'Variable description')} + /> + </Tooltip> + ); +} + +function renderDescriptionWithLinks(description: string, linkClassName: string) { + const elements: Array<string | JSX.Element> = []; + let nextKey = 0; + let cursor = 0; + + MARKDOWN_LINK_REGEX.lastIndex = 0; + + for (const match of description.matchAll(MARKDOWN_LINK_REGEX)) { + const matchStart = match.index ?? 0; + + if (matchStart > cursor) { + appendTextWithBareLinks(elements, description.slice(cursor, matchStart), linkClassName, () => nextKey++); + } + + const label = match[1]; + const url = match[2]; + elements.push(renderExternalLinkOrText(label, url, linkClassName, nextKey++)); + cursor = matchStart + match[0].length; + } + + if (cursor < description.length) { + appendTextWithBareLinks(elements, description.slice(cursor), linkClassName, () => nextKey++); + } + + return elements; +} + +function appendTextWithBareLinks( + elements: Array<string | JSX.Element>, + text: string, + linkClassName: string, + getNextKey: () => number +) { + BARE_LINK_REGEX.lastIndex = 0; + let cursor = 0; + + for (const match of text.matchAll(BARE_LINK_REGEX)) { + const matchStart = match.index ?? 0; + if (matchStart > cursor) { + elements.push(text.slice(cursor, matchStart)); + } + + const rawMatch = match[0]; + const trimmedUrl = rawMatch.replace(TRAILING_PUNCTUATION_REGEX, ''); + const trailingText = rawMatch.slice(trimmedUrl.length); + + elements.push(renderExternalLinkOrText(trimmedUrl, trimmedUrl, linkClassName, getNextKey())); + + if (trailingText) { + elements.push(trailingText); + } + + cursor = matchStart + rawMatch.length; + } + + if (cursor < text.length) { + elements.push(text.slice(cursor)); + } +} + +function renderExternalLinkOrText( + label: string, + url: string, + linkClassName: string, + key: number +): string | JSX.Element { + const safeUrl = getSafeExternalUrl(url); + + if (!safeUrl) { + return label; + } + + return ( + <a key={key} href={safeUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}> + {label} + </a> + ); +} + +function getSafeExternalUrl(url: string): string | undefined { + const trimmedUrl = url.trim(); + + if (!SAFE_PROTOCOL_REGEX.test(trimmedUrl)) { + return undefined; + } + + const sanitizedUrl = sanitizeUrl(trimmedUrl); + + if (sanitizedUrl === '' || sanitizedUrl === 'about:blank') { + return undefined; + } + + return sanitizedUrl; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + icon: css({ + color: theme.colors.text.secondary, + }), + tooltipContent: css({ + maxWidth: theme.spacing(40), + whiteSpace: 'normal', + wordBreak: 'break-word', + }), + link: css({ + color: theme.colors.primary.text, + textDecoration: 'underline', + }), +}); diff --git a/public/app/features/dashboard-scene/utils/dashboardControls.test.ts b/public/app/features/dashboard-scene/utils/dashboardControls.test.ts index 6a2644f647038..aaa716c172f95 100644 --- a/public/app/features/dashboard-scene/utils/dashboardControls.test.ts +++ b/public/app/features/dashboard-scene/utils/dashboardControls.test.ts @@ -230,6 +230,39 @@ describe('dashboardControls', () => { expect(result.defaultLinks).toEqual([]); }); + it('should preserve default variable descriptions from datasources', async () => { + const refs: DataSourceRef[] = [{ uid: 'ds-1', type: 'prometheus' }]; + const describedVariable: QueryVariableKind = { + ...mockVariable1, + spec: { + ...mockVariable1.spec, + description: 'Read docs at https://grafana.com/docs', + }, + }; + + const mockDs = createMockDatasource({ + uid: 'ds-1', + type: 'prometheus', + getDefaultVariables: () => Promise.resolve([describedVariable]), + getDefaultLinks: undefined, + getRef: jest.fn(() => ({ uid: 'ds-1', type: 'prometheus' })), + }); + + const mockSrv = createMockDataSourceSrv({ + get: jest.fn(() => Promise.resolve(mockDs as DataSourceApi<DataQuery, DataSourceJsonData>)), + }); + + getDataSourceSrvMock.mockReturnValue(mockSrv); + + const result = await loadDefaultControlsFromDatasources(refs); + + expect(result.defaultVariables[0]).toMatchObject({ + spec: { + description: 'Read docs at https://grafana.com/docs', + }, + }); + }); + it('should collect default links from datasources', async () => { const refs: DataSourceRef[] = [{ uid: 'ds-1', type: 'prometheus' }]; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1def12d8443c1..0dd7b64596a3d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6473,6 +6473,7 @@ "description": { "action": "Change variable description" }, + "description-tooltip": "Variable description", "hide": { "action": "Change variable hide option" },
← Back to Alerts View on GitHub →