XSS
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