XSS

MEDIUM
grafana/grafana
Commit: c16058059226
Affected: Pre-12.4.0 (i.e., 12.3.x and earlier)
2026-04-17 15:32 UTC

Description

The commit changes the Data Hover UI rendering to sanitize and constrain link rendering in data-hover tooltips. Previously, values could be rendered as clickable links via a shared geomap/ui utility (renderValue) that wrapped URLs in an anchor tag without URL sanitization. The new approach introduces a dedicated renderValue implementation under data-hover that only renders http/https URLs and sanitizes the href via textUtil.sanitizeUrl, while leaving non-http/https values as plain text. It also adds an isHttpUrl helper and tests for it. In effect, this mitigates potential XSS via untrusted inputs in Data Hover tooltips by preventing execution of untrusted or malicious URLs and ensuring sanitized, safe anchor targets.

Commit Details

Author: Leon Sorokin

Date: 2026-04-17 14:40 UTC

Message:

Chore: Prevent data-hover tooltip from pulling in geomap/utils -> ol (#122954) Co-authored-by: Paul Marbach <paul.marbach@grafana.com>

Triage Assessment

Vulnerability Type: XSS

Confidence: MEDIUM

Reasoning:

The commit introduces a safe rendering for values in data-hover tooltips by validating URLs and sanitizing links, preventing potential XSS via untrusted input. It replaces a prior dependency and adds isHttpUrl and renderValue logic to only render HTTP/HTTPS links with sanitization, reducing risk of harmful inputs being executed. This constitutes an input validation/unsafe-link mitigation in the UI.

Verification Assessment

Vulnerability Type: XSS

Confidence: MEDIUM

Affected Versions: Pre-12.4.0 (i.e., 12.3.x and earlier)

Code Diff

diff --git a/public/app/features/visualization/data-hover/DataHoverRows.tsx b/public/app/features/visualization/data-hover/DataHoverRows.tsx index eafb95d176061..5da3e9c54d5fd 100644 --- a/public/app/features/visualization/data-hover/DataHoverRows.tsx +++ b/public/app/features/visualization/data-hover/DataHoverRows.tsx @@ -7,9 +7,9 @@ import * as React from 'react'; import { type DataFrame, FieldType, getFieldDisplayName, type GrafanaTheme2 } from '@grafana/data'; import { Collapse, TabContent, useStyles2 } from '@grafana/ui'; import { type GeomapLayerHover } from 'app/plugins/panel/geomap/event'; -import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils'; import { DataHoverRow } from './DataHoverRow'; +import { renderValue } from './renderValue'; type Props = { layers: GeomapLayerHover[]; diff --git a/public/app/features/visualization/data-hover/DataHoverView.test.tsx b/public/app/features/visualization/data-hover/DataHoverView.test.tsx index d756d41b250ae..82433ed17ee55 100644 --- a/public/app/features/visualization/data-hover/DataHoverView.test.tsx +++ b/public/app/features/visualization/data-hover/DataHoverView.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import { arrayToDataFrame } from '@grafana/data'; import { DataHoverView } from './DataHoverView'; +import { isHttpUrl } from './renderValue'; describe('DataHoverView component', () => { it('should default to multi mode if mode is null or undefined', () => { @@ -12,3 +13,14 @@ describe('DataHoverView component', () => { expect(screen.queryByText('bar')).toBeInTheDocument(); }); }); + +describe('isHttpUrl', () => { + it.each([ + { url: 'https://example.com/path', expected: true }, + { url: 'http://localhost:3000', expected: true }, + { url: 'ftp://files.example.com', expected: false }, + { url: 'not a url', expected: false }, + ])('"$url" returns $expected', ({ url, expected }) => { + expect(isHttpUrl(url)).toBe(expected); + }); +}); diff --git a/public/app/features/visualization/data-hover/DataHoverView.tsx b/public/app/features/visualization/data-hover/DataHoverView.tsx index 40f5b0bb4b5a9..8ff3077ae86d2 100644 --- a/public/app/features/visualization/data-hover/DataHoverView.tsx +++ b/public/app/features/visualization/data-hover/DataHoverView.tsx @@ -10,9 +10,10 @@ import { } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { TextLink, useStyles2 } from '@grafana/ui'; -import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils'; import { getDataLinks } from 'app/plugins/panel/status-history/utils'; +import { renderValue } from './renderValue'; + export interface Props { data?: DataFrame; // source data rowIndex?: number | null; // the hover row diff --git a/public/app/features/visualization/data-hover/renderValue.tsx b/public/app/features/visualization/data-hover/renderValue.tsx new file mode 100644 index 0000000000000..295628d87338d --- /dev/null +++ b/public/app/features/visualization/data-hover/renderValue.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { textUtil } from '@grafana/data'; + +export const isHttpUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +export const renderValue = (value: string): string | React.ReactNode => { + if (!isHttpUrl(value)) { + return value; + } + + return ( + <a href={textUtil.sanitizeUrl(value)} target={'_blank'} className="external-link" rel="noreferrer"> + {value} + </a> + ); +}; diff --git a/public/app/plugins/panel/geomap/utils/uiUtils.tsx b/public/app/plugins/panel/geomap/utils/uiUtils.tsx deleted file mode 100644 index ad0a86abe628f..0000000000000 --- a/public/app/plugins/panel/geomap/utils/uiUtils.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { cx } from '@emotion/css'; -import * as React from 'react'; - -import { isUrl } from './utils'; - -export const renderValue = (value: string): string | React.ReactNode => { - if (isUrl(value)) { - return ( - <a href={value} target={'_blank'} className={cx('external-link')} rel="noreferrer"> - {value} - </a> - ); - } - - return value; -}; diff --git a/public/app/plugins/panel/geomap/utils/utils.test.ts b/public/app/plugins/panel/geomap/utils/utils.test.ts index bd50ef0cba03b..42a6bbcb5be90 100644 --- a/public/app/plugins/panel/geomap/utils/utils.test.ts +++ b/public/app/plugins/panel/geomap/utils/utils.test.ts @@ -34,7 +34,7 @@ jest.mock('app/plugins/datasource/grafana/datasource', () => ({ getGrafanaDatasource: jest.fn(), })); -import { hasVariableDependencies, hasLayerData, isUrl, isSegmentVisible } from './utils'; +import { hasVariableDependencies, hasLayerData, isSegmentVisible } from './utils'; // Test fixtures const createTestFeature = () => new Feature(new Point([0, 0])); @@ -187,17 +187,6 @@ describe('hasLayerData', () => { }); }); -describe('isUrl', () => { - it.each([ - { url: 'https://example.com/path', expected: true }, - { url: 'http://localhost:3000', expected: true }, - { url: 'ftp://files.example.com', expected: false }, - { url: 'not a url', expected: false }, - ])('isUrl($url) returns $expected', ({ url, expected }) => { - expect(isUrl(url)).toBe(expected); - }); -}); - describe('isSegmentVisible', () => { const map = { getPixelFromCoordinate: (coord: number[]) => coord, diff --git a/public/app/plugins/panel/geomap/utils/utils.ts b/public/app/plugins/panel/geomap/utils/utils.ts index a02855c63542d..be5592ef347b6 100644 --- a/public/app/plugins/panel/geomap/utils/utils.ts +++ b/public/app/plugins/panel/geomap/utils/utils.ts @@ -163,15 +163,6 @@ export function isSegmentVisible( return false; } -export const isUrl = (url: string) => { - try { - const newUrl = new URL(url); - return newUrl.protocol.includes('http'); - } catch (_) { - return false; - } -}; - /** * Checks if a layer has data to display * @param layer The OpenLayers layer to check
← Back to Alerts View on GitHub →