UI: Fix stored XSS via unescaped metric names and labels

Metric names, label names, and label values containing HTML/JavaScript were
inserted into `innerHTML` without escaping in several UI code paths, enabling
stored XSS attacks via crafted metrics. This mostly becomes exploitable in
Prometheus 3.x, since it defaults to allowing any UTF-8 characters in metric
and label names.

Apply `escapeHTML()` to all user-controlled values before innerHTML
insertion in:

* Mantine UI chart tooltip
* Old React UI chart tooltip
* Old React UI metrics explorer fuzzy search
* Old React UI heatmap tooltip

See https://github.com/prometheus/prometheus/security/advisories/GHSA-vffh-x6r8-xx99

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2026-04-04 11:54:22 +02:00 committed by Julien Pivotto
parent 1bd2f3a9fd
commit fddbccf79b
4 changed files with 16 additions and 15 deletions

View File

@ -76,7 +76,7 @@ const formatLabels = (labels: { [key: string]: string }): string => `
.filter((k) => k !== "__name__")
.map(
(k) =>
`<div><strong>${escapeHTML(k)}</strong>: ${escapeHTML(labels[k])}</div>`
`<div><strong>${escapeHTML(k)}</strong>: ${escapeHTML(labels[k])}</div>`,
)
.join("")}
</div>`;
@ -153,7 +153,7 @@ const tooltipPlugin = (useLocalTime: boolean, data: AlignedData) => {
<div class="date">${formatTimestamp(ts, useLocalTime)}</div>
<div class="series-value">
<span class="detail-swatch" style="background-color: ${color}"></span>
<span>${labels.__name__ ? labels.__name__ + ": " : " "}<strong>${value}</strong></span>
<span>${labels.__name__ ? escapeHTML(labels.__name__) + ": " : " "}<strong>${value}</strong></span>
</div>
${formatLabels(labels)}
`.trimEnd();
@ -193,7 +193,7 @@ const autoPadLeft = (
u: uPlot,
values: string[],
axisIdx: number,
cycleNum: number
cycleNum: number,
) => {
const axis = u.axes[axisIdx];
@ -208,7 +208,7 @@ const autoPadLeft = (
// Find longest tick text.
const longestVal = (values ?? []).reduce(
(acc, val) => (val.length > acc.length ? val : acc),
""
"",
);
if (longestVal != "") {
@ -228,7 +228,7 @@ const onlyDrawPointsForDisconnectedSamplesFilter = (
u: uPlot,
seriesIdx: number,
show: boolean,
gaps?: null | number[][]
gaps?: null | number[][],
) => {
const filtered = [];
@ -287,7 +287,7 @@ export const getUPlotOptions = (
useLocalTime: boolean,
yAxisMin: number | null,
light: boolean,
onSelectRange: (_start: number, _end: number) => void
onSelectRange: (_start: number, _end: number) => void,
): uPlot.Options => ({
width: width - 30,
height: 550,
@ -314,7 +314,7 @@ export const getUPlotOptions = (
markers: {
fill: (
_u: uPlot,
seriesIdx: number
seriesIdx: number,
): CSSStyleDeclaration["borderColor"] =>
// Because the index here is coming from uPlot, we need to subtract 1. Series 0
// represents the X axis, so we need to skip it.
@ -411,7 +411,7 @@ export const getUPlotOptions = (
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
labels: r.metric,
stroke: getSeriesColor(idx, light),
})
}),
),
],
hooks: {
@ -421,7 +421,7 @@ export const getUPlotOptions = (
const leftVal = self.posToVal(self.select.left, "x");
const rightVal = Math.max(
self.posToVal(self.select.left + self.select.width, "x"),
leftVal + 1
leftVal + 1,
);
onSelectRange(leftVal, rightVal);
@ -441,7 +441,7 @@ export const getUPlotData = (
inputData: RangeSamples[],
startTime: number,
endTime: number,
resolution: number
resolution: number,
): uPlot.AlignedData => {
const timeData: number[] = [];
for (let t = startTime; t <= endTime; t += resolution) {

View File

@ -118,10 +118,10 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
const formatLabels = (labels: { [key: string]: string }): string => `
<div class="labels">
${Object.keys(labels).length === 0 ? '<div class="mb-1 font-italic">no labels</div>' : ''}
${labels['__name__'] ? `<div class="mb-1"><strong>${labels['__name__']}</strong></div>` : ''}
${labels['__name__'] ? `<div class="mb-1"><strong>${escapeHTML(labels['__name__'])}</strong></div>` : ''}
${Object.keys(labels)
.filter((k) => k !== '__name__')
.map((k) => `<div class="mb-1"><strong>${k}</strong>: ${escapeHTML(labels[k])}</div>`)
.map((k) => `<div class="mb-1"><strong>${escapeHTML(k)}</strong>: ${escapeHTML(labels[k])}</div>`)
.join('')}
</div>`;
@ -129,7 +129,7 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
<div class="date">${dateTime.format('YYYY-MM-DD HH:mm:ss Z')}</div>
<div>
<span class="detail-swatch" style="background-color: ${color}"></span>
<span>${labels.__name__ || 'value'}: <strong>${yval}</strong></span>
<span>${labels.__name__ ? escapeHTML(labels.__name__) : 'value'}: <strong>${yval}</strong></span>
</div>
<div class="mt-2 mb-1 font-weight-bold">${'seriesLabels' in both ? 'Trace exemplar:' : 'Series:'}</div>
${formatLabels(labels)}

View File

@ -2,7 +2,7 @@ import React, { Component, ChangeEvent } from 'react';
import { Modal, ModalBody, ModalHeader, Input } from 'reactstrap';
import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy';
const fuz = new Fuzzy({ pre: '<strong>', post: '</strong>', shouldSort: true });
const fuz = new Fuzzy({ pre: '<strong>', post: '</strong>', shouldSort: true, escapeHTML: true });
interface MetricsExplorerProps {
show: boolean;

View File

@ -6,6 +6,7 @@ See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3384 for more deta
import moment from 'moment-timezone';
import {formatValue} from "../../pages/graph/GraphHelpers";
import {escapeHTML} from '../../utils';
const TOOLTIP_ID = 'heatmap-tooltip';
const GRADIENT_STEPS = 16;
@ -82,7 +83,7 @@ const GRADIENT_STEPS = 16;
tooltip.className = cssClass;
const timeHtml = `<div class="date">${dateTime.join('<br>')}</div>`
const labelHtml = `<div>Bucket: ${label || 'value'}</div>`
const labelHtml = `<div>Bucket: ${label ? escapeHTML(label) : 'value'}</div>`
const valueHtml = `<div>Value: <strong>${value}</strong></div>`
tooltip.innerHTML = `<div>${timeHtml}<div>${labelHtml}${valueHtml}</div></div>`;