From 1e73d2fcded13042b75842a6af47c969f7f52909 Mon Sep 17 00:00:00 2001 From: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:58:10 +0200 Subject: [PATCH 1/6] discovery/consul: add health_filter for Health API filtering The filter field was documented as targeting the Catalog API but since PR #17349 it was also passed to the Health API. This broke existing configs using Catalog-only fields like ServiceTags, which the Health API rejects (it uses Service.Tags instead). Introduce a separate health_filter field that is passed exclusively to the Health API, while filter remains catalog-only. Update the docs to explain the two-phase discovery (Catalog for service listing, Health for instances) and the field name differences between the two APIs. Fixes #18479 Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> --- discovery/consul/consul.go | 67 ++++++++++++++++------------- discovery/consul/consul_test.go | 27 +++++++----- docs/configuration/configuration.md | 23 +++++++--- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/discovery/consul/consul.go b/discovery/consul/consul.go index 4cc4003b2c..19ccd22e7b 100644 --- a/discovery/consul/consul.go +++ b/discovery/consul/consul.go @@ -116,9 +116,12 @@ type SDConfig struct { ServiceTags []string `yaml:"tags,omitempty"` // Desired node metadata. As of Consul 1.14, consider `filter` instead. NodeMeta map[string]string `yaml:"node_meta,omitempty"` - // Consul filter string - // See https://www.consul.io/api-docs/catalog#filtering-1, for syntax + // Filter expression for the Catalog API. + // See https://developer.hashicorp.com/consul/api-docs/catalog#filtering for syntax. Filter string `yaml:"filter,omitempty"` + // Filter expression for the Health API. + // See https://developer.hashicorp.com/consul/api-docs/health#filtering for syntax. + HealthFilter string `yaml:"health_filter,omitempty"` HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` } @@ -170,20 +173,21 @@ func (c *SDConfig) UnmarshalYAML(unmarshal func(any) error) error { // Discovery retrieves target information from a Consul server // and updates them via watches. type Discovery struct { - client *consul.Client - clientDatacenter string - clientNamespace string - clientPartition string - tagSeparator string - watchedServices []string // Set of services which will be discovered. - watchedTags []string // Tags used to filter instances of a service. - watchedNodeMeta map[string]string - watchedFilter string - allowStale bool - refreshInterval time.Duration - finalizer func() - logger *slog.Logger - metrics *consulMetrics + client *consul.Client + clientDatacenter string + clientNamespace string + clientPartition string + tagSeparator string + watchedServices []string // Set of services which will be discovered. + watchedTags []string // Tags used to filter instances of a service. + watchedNodeMeta map[string]string + watchedFilter string + watchedHealthFilter string + allowStale bool + refreshInterval time.Duration + finalizer func() + logger *slog.Logger + metrics *consulMetrics } // NewDiscovery returns a new Discovery for the given config. @@ -218,20 +222,21 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove return nil, err } cd := &Discovery{ - client: client, - tagSeparator: conf.TagSeparator, - watchedServices: conf.Services, - watchedTags: conf.ServiceTags, - watchedNodeMeta: conf.NodeMeta, - watchedFilter: conf.Filter, - allowStale: conf.AllowStale, - refreshInterval: time.Duration(conf.RefreshInterval), - clientDatacenter: conf.Datacenter, - clientNamespace: conf.Namespace, - clientPartition: conf.Partition, - finalizer: wrapper.CloseIdleConnections, - logger: logger, - metrics: m, + client: client, + tagSeparator: conf.TagSeparator, + watchedServices: conf.Services, + watchedTags: conf.ServiceTags, + watchedNodeMeta: conf.NodeMeta, + watchedFilter: conf.Filter, + watchedHealthFilter: conf.HealthFilter, + allowStale: conf.AllowStale, + refreshInterval: time.Duration(conf.RefreshInterval), + clientDatacenter: conf.Datacenter, + clientNamespace: conf.Namespace, + clientPartition: conf.Partition, + finalizer: wrapper.CloseIdleConnections, + logger: logger, + metrics: m, } return cd, nil @@ -499,7 +504,7 @@ func (srv *consulService) watch(ctx context.Context, ch chan<- []*targetgroup.Gr WaitTime: watchTimeout, AllowStale: srv.discovery.allowStale, NodeMeta: srv.discovery.watchedNodeMeta, - Filter: srv.discovery.watchedFilter, + Filter: srv.discovery.watchedHealthFilter, } t0 := time.Now() diff --git a/discovery/consul/consul_test.go b/discovery/consul/consul_test.go index 32394dcc00..378d35b4bf 100644 --- a/discovery/consul/consul_test.go +++ b/discovery/consul/consul_test.go @@ -240,8 +240,6 @@ func newServer(t *testing.T) (*httptest.Server, *SDConfig) { response = ServiceTestAnswer case "/v1/health/service/test?wait=120000ms": response = ServiceTestAnswer - case "/v1/health/service/test?filter=NodeMeta.rack_name+%3D%3D+%222304%22&wait=120000ms": - response = ServiceTestAnswer case "/v1/health/service/other?wait=120000ms": response = `[]` case "/v1/catalog/services?node-meta=rack_name%3A2304&stale=&wait=120000ms": @@ -394,21 +392,28 @@ func TestFilterOption(t *testing.T) { cancel() } -// TestFilterOnHealthEndpoint verifies that filter is passed to health service endpoint. -func TestFilterOnHealthEndpoint(t *testing.T) { - filterReceived := false +// TestHealthFilterOption verifies that health_filter is passed to the health service endpoint +// and not to the catalog endpoint. +func TestHealthFilterOption(t *testing.T) { + catalogFilterReceived := false + healthFilterReceived := false stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := "" switch r.URL.Path { case "/v1/agent/self": response = AgentAnswer case "/v1/health/service/test": - // Verify filter parameter is present in the query filter := r.URL.Query().Get("filter") - if filter == `Node.Meta.rack_name == "2304"` { - filterReceived = true + if filter == `Service.Tags contains "canary"` { + healthFilterReceived = true } response = ServiceTestAnswer + case "/v1/catalog/services": + filter := r.URL.Query().Get("filter") + if filter != "" { + catalogFilterReceived = true + } + response = ServicesTestAnswer default: t.Errorf("Unhandled consul call: %s", r.URL) } @@ -423,7 +428,7 @@ func TestFilterOnHealthEndpoint(t *testing.T) { config := &SDConfig{ Server: stuburl.Host, Services: []string{"test"}, - Filter: `Node.Meta.rack_name == "2304"`, + HealthFilter: `Service.Tags contains "canary"`, RefreshInterval: model.Duration(1 * time.Second), } @@ -438,8 +443,8 @@ func TestFilterOnHealthEndpoint(t *testing.T) { checkOneTarget(t, <-ch) cancel() - // Verify the filter was actually sent to the health endpoint - require.True(t, filterReceived, "Filter parameter should be sent to health service endpoint") + require.True(t, healthFilterReceived, "health_filter should be sent to the health service endpoint.") + require.False(t, catalogFilterReceived, "health_filter should not be sent to the catalog endpoint.") } func TestGetDatacenterShouldReturnError(t *testing.T) { diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 3682348e67..a2484ca0fb 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -1417,7 +1417,17 @@ subscription_id: ### `` Consul SD configurations allow retrieving scrape targets from [Consul's](https://www.consul.io) -Catalog API. +service catalog. Discovery uses two Consul API endpoints: + +1. The [Catalog API](https://developer.hashicorp.com/consul/api-docs/catalog) to list services + (used when `services` is empty, or when `tags` or `filter` are set). +2. The [Health API](https://developer.hashicorp.com/consul/api-docs/health) to retrieve service + instances and their health status. + +Because these two APIs have different filtering field schemas, Prometheus exposes separate filter +options for each: `filter` applies to the Catalog API and `health_filter` applies to the Health API. +For example, tags are exposed as `ServiceTags` in the Catalog API but as `Service.Tags` in the +Health API. The following meta labels are available on targets during [relabeling](#relabel_config): @@ -1457,17 +1467,18 @@ The following meta labels are available on targets during [relabeling](#relabel_ services: [ - ] -# A Consul Filter expression used to filter the catalog results -# See https://www.consul.io/api-docs/catalog#list-services to know more -# about the filter expressions that can be used. +# Filter expression for the Catalog API. See https://developer.hashicorp.com/consul/api-docs/catalog#filtering for syntax. [ filter: ] -# The `tags` and `node_meta` fields are deprecated in Consul in favor of `filter`. +# Filter expression for the Health API. See https://developer.hashicorp.com/consul/api-docs/health#filtering for syntax. +[ health_filter: ] + +# The `tags` and `node_meta` fields are deprecated in favor of `filter` and `health_filter`. # An optional list of tags used to filter nodes for a given service. Services must contain all tags in the list. tags: [ - ] -# Node metadata key/value pairs to filter nodes for a given service. As of Consul 1.14, consider `filter` instead. +# Node metadata key/value pairs to filter nodes for a given service. As of Consul 1.14, consider `health_filter` instead. [ node_meta: [ : ... ] ] From 4cc50803ff1b494403a81c8ea5f566685f12de5b Mon Sep 17 00:00:00 2001 From: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:26:40 +0200 Subject: [PATCH 2/6] discovery/consul: fix catalog watch trigger and improve filter tests When health_filter is set without explicit services, the catalog needs to be watched to enumerate services. Add watchedFilter to the condition that triggers catalog watching. Improve the filter test suite: - Replace defer with t.Cleanup for stub servers. - Rewrite TestFilterOption to assert that the catalog receives the filter and the health endpoint does not. - Rewrite TestHealthFilterOption to assert that health_filter is routed correctly to the health endpoint only. - Add TestBothFiltersOption to verify both filters are routed to their respective endpoints when both are configured. Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> --- discovery/consul/consul.go | 2 +- discovery/consul/consul_test.go | 200 ++++++++++++++++++++++++-------- 2 files changed, 151 insertions(+), 51 deletions(-) diff --git a/discovery/consul/consul.go b/discovery/consul/consul.go index 19ccd22e7b..a8ffe5e8d3 100644 --- a/discovery/consul/consul.go +++ b/discovery/consul/consul.go @@ -335,7 +335,7 @@ func (d *Discovery) Run(ctx context.Context, ch chan<- []*targetgroup.Group) { } d.initialize(ctx) - if len(d.watchedServices) == 0 || len(d.watchedTags) != 0 { + if len(d.watchedServices) == 0 || len(d.watchedTags) != 0 || d.watchedFilter != "" { // We need to watch the catalog. ticker := time.NewTicker(d.refreshInterval) diff --git a/discovery/consul/consul_test.go b/discovery/consul/consul_test.go index 378d35b4bf..a92e37d4ce 100644 --- a/discovery/consul/consul_test.go +++ b/discovery/consul/consul_test.go @@ -295,7 +295,7 @@ func checkOneTarget(t *testing.T, tg []*targetgroup.Group) { // Watch all the services in the catalog. func TestAllServices(t *testing.T) { stub, config := newServer(t) - defer stub.Close() + t.Cleanup(stub.Close) d := newDiscovery(t, config) @@ -314,7 +314,7 @@ func TestAllServices(t *testing.T) { // targetgroup with no targets is emitted if no services were discovered. func TestNoTargets(t *testing.T) { stub, config := newServer(t) - defer stub.Close() + t.Cleanup(stub.Close) config.ServiceTags = []string{"missing"} d := newDiscovery(t, config) @@ -335,7 +335,7 @@ func TestNoTargets(t *testing.T) { // Watch only the test service. func TestOneService(t *testing.T) { stub, config := newServer(t) - defer stub.Close() + t.Cleanup(stub.Close) config.Services = []string{"test"} d := newDiscovery(t, config) @@ -350,7 +350,7 @@ func TestOneService(t *testing.T) { // Watch the test service with a specific tag and node-meta. func TestAllOptions(t *testing.T) { stub, config := newServer(t) - defer stub.Close() + t.Cleanup(stub.Close) config.Services = []string{"test"} config.NodeMeta = map[string]string{"rack_name": "2304"} @@ -371,68 +371,46 @@ func TestAllOptions(t *testing.T) { <-ch } -// Watch the test service with a specific tag and node-meta via Filter parameter. +// TestFilterOption verifies that when services and filter are both configured, the Catalog API +// is still called and receives the filter parameter, while the Health API does not. func TestFilterOption(t *testing.T) { - stub, config := newServer(t) - defer stub.Close() + var ( + catalogCalled bool + catalogFilter string + healthCalled bool + healthFilter string + ) - config.Services = []string{"test"} - config.Filter = `NodeMeta.rack_name == "2304"` - config.Token = "fake-token" - - d := newDiscovery(t, config) - - ctx, cancel := context.WithCancel(context.Background()) - ch := make(chan []*targetgroup.Group) - go func() { - d.Run(ctx, ch) - close(ch) - }() - checkOneTarget(t, <-ch) - cancel() -} - -// TestHealthFilterOption verifies that health_filter is passed to the health service endpoint -// and not to the catalog endpoint. -func TestHealthFilterOption(t *testing.T) { - catalogFilterReceived := false - healthFilterReceived := false stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := "" + w.Header().Add("X-Consul-Index", "1") switch r.URL.Path { case "/v1/agent/self": - response = AgentAnswer - case "/v1/health/service/test": - filter := r.URL.Query().Get("filter") - if filter == `Service.Tags contains "canary"` { - healthFilterReceived = true - } - response = ServiceTestAnswer + w.Write([]byte(AgentAnswer)) case "/v1/catalog/services": - filter := r.URL.Query().Get("filter") - if filter != "" { - catalogFilterReceived = true - } - response = ServicesTestAnswer + catalogCalled = true + catalogFilter = r.URL.Query().Get("filter") + w.Write([]byte(`{"test": []}`)) + case "/v1/health/service/test": + healthCalled = true + healthFilter = r.URL.Query().Get("filter") + w.Write([]byte(ServiceTestAnswer)) default: t.Errorf("Unhandled consul call: %s", r.URL) } - w.Header().Add("X-Consul-Index", "1") - w.Write([]byte(response)) })) - defer stub.Close() + t.Cleanup(stub.Close) stuburl, err := url.Parse(stub.URL) require.NoError(t, err) - config := &SDConfig{ + cfg := &SDConfig{ Server: stuburl.Host, Services: []string{"test"}, - HealthFilter: `Service.Tags contains "canary"`, + Filter: `NodeMeta.rack_name == "2304"`, RefreshInterval: model.Duration(1 * time.Second), } - d := newDiscovery(t, config) + d := newDiscovery(t, cfg) ctx, cancel := context.WithCancel(context.Background()) ch := make(chan []*targetgroup.Group) @@ -441,10 +419,132 @@ func TestHealthFilterOption(t *testing.T) { close(ch) }() checkOneTarget(t, <-ch) + // All handler writes happened-before the channel receive above. + require.True(t, catalogCalled, "Catalog endpoint should be called when filter is set alongside services.") + require.Equal(t, `NodeMeta.rack_name == "2304"`, catalogFilter, "Catalog should receive the filter parameter.") + require.True(t, healthCalled, "Health endpoint should be called.") + require.Empty(t, healthFilter, "Health endpoint should not receive the catalog filter.") cancel() + for range ch { + } +} - require.True(t, healthFilterReceived, "health_filter should be sent to the health service endpoint.") - require.False(t, catalogFilterReceived, "health_filter should not be sent to the catalog endpoint.") +// TestHealthFilterOption verifies that health_filter is passed to the Health API and not to +// the Catalog API. +func TestHealthFilterOption(t *testing.T) { + var ( + catalogCalled bool + catalogFilter string + healthCalled bool + healthFilter string + ) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Consul-Index", "1") + switch r.URL.Path { + case "/v1/agent/self": + w.Write([]byte(AgentAnswer)) + case "/v1/catalog/services": + catalogCalled = true + catalogFilter = r.URL.Query().Get("filter") + w.Write([]byte(`{"test": []}`)) + case "/v1/health/service/test": + healthCalled = true + healthFilter = r.URL.Query().Get("filter") + w.Write([]byte(ServiceTestAnswer)) + default: + t.Errorf("Unhandled consul call: %s", r.URL) + } + })) + t.Cleanup(stub.Close) + + stuburl, err := url.Parse(stub.URL) + require.NoError(t, err) + + // No services configured: catalog path is always used, allowing us to assert + // that health_filter is not forwarded to the Catalog API. + cfg := &SDConfig{ + Server: stuburl.Host, + HealthFilter: `Service.Tags contains "canary"`, + RefreshInterval: model.Duration(1 * time.Second), + } + + d := newDiscovery(t, cfg) + + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan []*targetgroup.Group) + go func() { + d.Run(ctx, ch) + close(ch) + }() + checkOneTarget(t, <-ch) + // All handler writes happened-before the channel receive above. + require.True(t, catalogCalled, "Catalog endpoint should be called.") + require.Empty(t, catalogFilter, "Catalog should not receive the health_filter parameter.") + require.True(t, healthCalled, "Health endpoint should be called.") + require.Equal(t, `Service.Tags contains "canary"`, healthFilter, "Health endpoint should receive the health_filter parameter.") + cancel() + for range ch { + } +} + +// TestBothFiltersOption verifies that when both filter and health_filter are configured, +// each filter is sent exclusively to its respective API endpoint. +func TestBothFiltersOption(t *testing.T) { + var ( + catalogCalled bool + catalogFilter string + healthCalled bool + healthFilter string + ) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Consul-Index", "1") + switch r.URL.Path { + case "/v1/agent/self": + w.Write([]byte(AgentAnswer)) + case "/v1/catalog/services": + catalogCalled = true + catalogFilter = r.URL.Query().Get("filter") + w.Write([]byte(`{"test": []}`)) + case "/v1/health/service/test": + healthCalled = true + healthFilter = r.URL.Query().Get("filter") + w.Write([]byte(ServiceTestAnswer)) + default: + t.Errorf("Unhandled consul call: %s", r.URL) + } + })) + t.Cleanup(stub.Close) + + stuburl, err := url.Parse(stub.URL) + require.NoError(t, err) + + cfg := &SDConfig{ + Server: stuburl.Host, + Services: []string{"test"}, + Filter: `NodeMeta.rack_name == "2304"`, + HealthFilter: `Service.Tags contains "canary"`, + RefreshInterval: model.Duration(1 * time.Second), + } + + d := newDiscovery(t, cfg) + + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan []*targetgroup.Group) + go func() { + d.Run(ctx, ch) + close(ch) + }() + checkOneTarget(t, <-ch) + // All handler writes happened-before the channel receive above. + require.True(t, catalogCalled, "Catalog endpoint should be called when filter is set.") + require.Equal(t, `NodeMeta.rack_name == "2304"`, catalogFilter, "Catalog should receive only the catalog filter.") + require.True(t, healthCalled, "Health endpoint should be called.") + require.Equal(t, `Service.Tags contains "canary"`, healthFilter, "Health endpoint should receive only the health_filter.") + cancel() + for range ch { + } } func TestGetDatacenterShouldReturnError(t *testing.T) { @@ -476,7 +576,7 @@ func TestGetDatacenterShouldReturnError(t *testing.T) { Token: "fake-token", RefreshInterval: model.Duration(1 * time.Second), } - defer stub.Close() + t.Cleanup(stub.Close) d := newDiscovery(t, config) // Should be empty if not initialized. From fddbccf79bc81a870dd455dfd1c8368d31b93a4b Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Sat, 4 Apr 2026 11:54:22 +0200 Subject: [PATCH 3/6] 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 --- .../src/pages/query/uPlotChartHelpers.ts | 20 +++++++++---------- .../react-app/src/pages/graph/GraphHelpers.ts | 6 +++--- .../src/pages/graph/MetricsExplorer.tsx | 2 +- .../src/vendor/flot/jquery.flot.heatmap.js | 3 ++- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts b/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts index 816ddf7578..afb09bfb4b 100644 --- a/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts +++ b/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts @@ -76,7 +76,7 @@ const formatLabels = (labels: { [key: string]: string }): string => ` .filter((k) => k !== "__name__") .map( (k) => - `
${escapeHTML(k)}: ${escapeHTML(labels[k])}
` + `
${escapeHTML(k)}: ${escapeHTML(labels[k])}
`, ) .join("")} `; @@ -153,7 +153,7 @@ const tooltipPlugin = (useLocalTime: boolean, data: AlignedData) => {
${formatTimestamp(ts, useLocalTime)}
- ${labels.__name__ ? labels.__name__ + ": " : " "}${value} + ${labels.__name__ ? escapeHTML(labels.__name__) + ": " : " "}${value}
${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) { diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.ts index 21bb768f52..60b38d5ab0 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.ts @@ -118,10 +118,10 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot const formatLabels = (labels: { [key: string]: string }): string => `
${Object.keys(labels).length === 0 ? '
no labels
' : ''} - ${labels['__name__'] ? `
${labels['__name__']}
` : ''} + ${labels['__name__'] ? `
${escapeHTML(labels['__name__'])}
` : ''} ${Object.keys(labels) .filter((k) => k !== '__name__') - .map((k) => `
${k}: ${escapeHTML(labels[k])}
`) + .map((k) => `
${escapeHTML(k)}: ${escapeHTML(labels[k])}
`) .join('')}
`; @@ -129,7 +129,7 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
${dateTime.format('YYYY-MM-DD HH:mm:ss Z')}
- ${labels.__name__ || 'value'}: ${yval} + ${labels.__name__ ? escapeHTML(labels.__name__) : 'value'}: ${yval}
${'seriesLabels' in both ? 'Trace exemplar:' : 'Series:'}
${formatLabels(labels)} diff --git a/web/ui/react-app/src/pages/graph/MetricsExplorer.tsx b/web/ui/react-app/src/pages/graph/MetricsExplorer.tsx index 1959fa8265..053c630f38 100644 --- a/web/ui/react-app/src/pages/graph/MetricsExplorer.tsx +++ b/web/ui/react-app/src/pages/graph/MetricsExplorer.tsx @@ -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: '', post: '', shouldSort: true }); +const fuz = new Fuzzy({ pre: '', post: '', shouldSort: true, escapeHTML: true }); interface MetricsExplorerProps { show: boolean; diff --git a/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js b/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js index 29d5c81ef7..be21821fdd 100644 --- a/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js +++ b/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js @@ -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 = `
${dateTime.join('
')}
` - const labelHtml = `
Bucket: ${label || 'value'}
` + const labelHtml = `
Bucket: ${label ? escapeHTML(label) : 'value'}
` const valueHtml = `
Value: ${value}
` tooltip.innerHTML = `
${timeHtml}
${labelHtml}${valueHtml}
`; From d09ea56b38b14b004f4399c3bf71698774c44369 Mon Sep 17 00:00:00 2001 From: Julien <291750+roidelapluie@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:50:54 +0200 Subject: [PATCH 4/6] Update docs/configuration/configuration.md Co-authored-by: George Krajcsovits Signed-off-by: Julien <291750+roidelapluie@users.noreply.github.com> --- docs/configuration/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index a2484ca0fb..d6d6d667e1 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -1478,7 +1478,7 @@ services: tags: [ - ] -# Node metadata key/value pairs to filter nodes for a given service. As of Consul 1.14, consider `health_filter` instead. +# Node metadata key/value pairs to filter nodes for a given service. As of Consul 1.14, consider `filter` or `health_filter` instead. [ node_meta: [ : ... ] ] From 06b7f1f6250e98db4850335c73fc2da768010993 Mon Sep 17 00:00:00 2001 From: Mohammad Varmazyar Date: Fri, 10 Apr 2026 23:36:56 +0200 Subject: [PATCH 5/6] config: add consul health_filter fixture coverage Signed-off-by: Mohammad Varmazyar --- config/config_test.go | 1 + config/testdata/conf.good.yml | 1 + config/testdata/roundtrip.good.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/config/config_test.go b/config/config_test.go index 1bae23d9d4..feb443fccf 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -481,6 +481,7 @@ var expectedConf = &Config{ PathPrefix: "/consul", Token: "mysecret", Services: []string{"nginx", "cache", "mysql"}, + HealthFilter: `Service.Tags contains "canary"`, ServiceTags: []string{"canary", "v1"}, NodeMeta: map[string]string{"rack": "123"}, TagSeparator: consul.DefaultSDConfig.TagSeparator, diff --git a/config/testdata/conf.good.yml b/config/testdata/conf.good.yml index d6b1690243..15728dc37a 100644 --- a/config/testdata/conf.good.yml +++ b/config/testdata/conf.good.yml @@ -182,6 +182,7 @@ scrape_configs: token: mysecret path_prefix: /consul services: ["nginx", "cache", "mysql"] + health_filter: 'Service.Tags contains "canary"' tags: ["canary", "v1"] node_meta: rack: "123" diff --git a/config/testdata/roundtrip.good.yml b/config/testdata/roundtrip.good.yml index 24ab7d2592..d999dafd32 100644 --- a/config/testdata/roundtrip.good.yml +++ b/config/testdata/roundtrip.good.yml @@ -41,6 +41,7 @@ scrape_configs: - server: localhost:1234 token: services: [nginx, cache, mysql] + health_filter: 'Service.Tags contains "canary"' tags: [canary, v1] node_meta: rack: "123" From f08b9837f92d6c6f576a66bb3ffc6526705af2b7 Mon Sep 17 00:00:00 2001 From: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:12:11 +0200 Subject: [PATCH 6/6] Release 3.11.2 Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- web/ui/mantine-ui/package.json | 4 ++-- web/ui/module/codemirror-promql/package.json | 4 ++-- web/ui/module/lezer-promql/package.json | 2 +- web/ui/package-lock.json | 14 +++++++------- web/ui/package.json | 2 +- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14baf6f01d..97cd81d170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 3.11.2 / 2026-04-13 + +This release has a fix for a Stored XSS vulnerability that can be triggered via crafted metric names and label values in Prometheus web UI tooltips and metrics explorer. Thanks to Duc Anh Nguyen from TinyxLab for reporting it. + +- [SECURITY] UI: Fix stored XSS via unescaped metric names and labels. CVE-2026-40179. #18506 +- [ENHANCEMENT] Consul SD: Introduce `health_filter` field for Health API filtering. #18499 +- [BUGFIX] Consul SD: Fix filter parameter being incorrectly applied to the Health API. #18499 + ## 3.11.1 / 2026-04-07 - [BUGFIX] Tracing: Fix startup failure for OTLP HTTP tracing with `insecure: true`. #18469 diff --git a/VERSION b/VERSION index 371cfe355d..1e33456831 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.11.1 +3.11.2 diff --git a/web/ui/mantine-ui/package.json b/web/ui/mantine-ui/package.json index 7b2b5acfa9..6af3419aca 100644 --- a/web/ui/mantine-ui/package.json +++ b/web/ui/mantine-ui/package.json @@ -1,7 +1,7 @@ { "name": "@prometheus-io/mantine-ui", "private": true, - "version": "0.311.1", + "version": "0.311.2", "type": "module", "scripts": { "start": "vite", @@ -28,7 +28,7 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nexucis/fuzzy": "^0.5.1", "@nexucis/kvsearch": "^0.9.1", - "@prometheus-io/codemirror-promql": "0.311.1", + "@prometheus-io/codemirror-promql": "0.311.2", "@reduxjs/toolkit": "^2.11.2", "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "^5.95.2", diff --git a/web/ui/module/codemirror-promql/package.json b/web/ui/module/codemirror-promql/package.json index fc7de423a3..8f12a5c31e 100644 --- a/web/ui/module/codemirror-promql/package.json +++ b/web/ui/module/codemirror-promql/package.json @@ -1,6 +1,6 @@ { "name": "@prometheus-io/codemirror-promql", - "version": "0.311.1", + "version": "0.311.2", "description": "a CodeMirror mode for the PromQL language", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/prometheus/prometheus/blob/main/web/ui/module/codemirror-promql/README.md", "dependencies": { - "@prometheus-io/lezer-promql": "0.311.1", + "@prometheus-io/lezer-promql": "0.311.2", "lru-cache": "^11.2.7" }, "devDependencies": { diff --git a/web/ui/module/lezer-promql/package.json b/web/ui/module/lezer-promql/package.json index 6b7238522c..23818f2bba 100644 --- a/web/ui/module/lezer-promql/package.json +++ b/web/ui/module/lezer-promql/package.json @@ -1,6 +1,6 @@ { "name": "@prometheus-io/lezer-promql", - "version": "0.311.1", + "version": "0.311.2", "description": "lezer-based PromQL grammar", "main": "dist/index.cjs", "type": "module", diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 67636d8609..d171bc249f 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "prometheus-io", - "version": "0.311.1", + "version": "0.311.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prometheus-io", - "version": "0.311.1", + "version": "0.311.2", "workspaces": [ "mantine-ui", "module/*" @@ -24,7 +24,7 @@ }, "mantine-ui": { "name": "@prometheus-io/mantine-ui", - "version": "0.311.1", + "version": "0.311.2", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/language": "^6.12.3", @@ -42,7 +42,7 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nexucis/fuzzy": "^0.5.1", "@nexucis/kvsearch": "^0.9.1", - "@prometheus-io/codemirror-promql": "0.311.1", + "@prometheus-io/codemirror-promql": "0.311.2", "@reduxjs/toolkit": "^2.11.2", "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "^5.95.2", @@ -172,10 +172,10 @@ }, "module/codemirror-promql": { "name": "@prometheus-io/codemirror-promql", - "version": "0.311.1", + "version": "0.311.2", "license": "Apache-2.0", "dependencies": { - "@prometheus-io/lezer-promql": "0.311.1", + "@prometheus-io/lezer-promql": "0.311.2", "lru-cache": "^11.2.7" }, "devDependencies": { @@ -205,7 +205,7 @@ }, "module/lezer-promql": { "name": "@prometheus-io/lezer-promql", - "version": "0.311.1", + "version": "0.311.2", "license": "Apache-2.0", "devDependencies": { "@lezer/generator": "^1.8.0", diff --git a/web/ui/package.json b/web/ui/package.json index cd2e902c89..cad35c9697 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -1,7 +1,7 @@ { "name": "prometheus-io", "description": "Monorepo for the Prometheus UI", - "version": "0.311.1", + "version": "0.311.2", "private": true, "scripts": { "build": "bash build_ui.sh --all",