This is an automated email from the ASF dual-hosted git repository.

Duansg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new a5496d6804 [fix] validate the metric history query inputs (#4274)
a5496d6804 is described below

commit a5496d6804311b0b499e5163bdd6f49d83fb73e7
Author: Duansg <[email protected]>
AuthorDate: Sun Aug 9 22:09:21 2026 +0800

    [fix] validate the metric history query inputs (#4274)
---
 .../service/impl/MetricsDataServiceImpl.java       |  57 ++++++++
 .../service/impl/MetricsDataServiceImplTest.java   | 150 +++++++++++++++++++++
 2 files changed, 207 insertions(+)

diff --git 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImpl.java
 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImpl.java
index b1595c241d..df336e5e3a 100644
--- 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImpl.java
+++ 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImpl.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.constants.CommonConstants;
 import org.apache.hertzbeat.common.constants.MetricDataConstants;
@@ -45,6 +46,38 @@ import org.springframework.stereotype.Service;
 @Service
 public class MetricsDataServiceImpl implements MetricsDataService {
 
+    /**
+     * The history range is interpolated straight into the time predicate of 
the generated
+     * query - {@code WHERE ts >= now - %s} for tdengine, the equivalent for 
influxdb, iotdb
+     * and victoria metrics - with no quoting around it, which makes it the 
widest opening
+     * of the four inputs: anything after the range escapes the predicate and 
continues the
+     * statement.
+     *
+     * <p>A count followed by a single unit letter is the whole language the 
ui speaks
+     * ({@code 1h}, {@code 6h}, {@code 1D}, {@code 1W}, {@code 4W}, {@code 
12W}) and cannot
+     * carry a quote, a separator or a comment. Both cases are accepted 
because the storages
+     * differ on it: questdb lowercases the unit while `TimePeriodUtil` reads 
an uppercase
+     * unit as a calendar period. Which units a given storage actually 
supports is left to
+     * that storage, so this rejects without changing what already worked.
+     */
+    private static final Pattern HISTORY_RANGE = 
Pattern.compile("\\d{1,6}[smhdwy]", Pattern.CASE_INSENSITIVE);
+
+    /**
+     * App, metrics group and metric names reach the storages as table and 
column
+     * identifiers, quoted with backticks in tdengine and double quotes in 
questdb, and as
+     * promql label values in victoria metrics. None of the characters allowed 
here can
+     * close any of those, and every monitoring template shipped with 
hertzbeat names its
+     * apps, metric groups and fields from this set.
+     */
+    private static final Pattern IDENTIFIER = 
Pattern.compile("[A-Za-z0-9_-]{1,200}");
+
+    /**
+     * The instance is commonly an address, so it additionally allows the 
punctuation an
+     * address carries. The storages that build a table name from it already 
fold {@code .},
+     * {@code :}, {@code [} and {@code ]} into underscores.
+     */
+    private static final Pattern INSTANCE = 
Pattern.compile("[A-Za-z0-9_\\-.:\\[\\]]{1,200}");
+
     private final RealTimeDataReader realTimeDataReader;
 
     private final Optional<HistoryDataReader> historyDataReader;
@@ -111,6 +144,11 @@ public class MetricsDataServiceImpl implements 
MetricsDataService {
         if (history == null) {
             history = "6h";
         }
+        validateHistoryRange(history);
+        validateIdentifier(app, "app");
+        validateIdentifier(metrics, "metrics");
+        validateIdentifier(metric, "metric");
+        validateInstance(instance);
         Map<String, List<Value>> instanceValuesMap;
         if (interval == null || !interval) {
             instanceValuesMap = 
historyDataReader.get().getHistoryMetricData(instance, app, metrics, metric, 
history);
@@ -126,4 +164,23 @@ public class MetricsDataServiceImpl implements 
MetricsDataService {
                 
.field(Field.builder().name(metric).type(CommonConstants.TYPE_NUMBER).build())
                 .build();
     }
+
+    private static void validateHistoryRange(String history) {
+        if (!HISTORY_RANGE.matcher(history).matches()) {
+            throw new IllegalArgumentException("history range: " + history
+                    + " is illegal, expected a count followed by a unit such 
as 6h or 1W.");
+        }
+    }
+
+    private static void validateIdentifier(String value, String name) {
+        if (value == null || !IDENTIFIER.matcher(value).matches()) {
+            throw new IllegalArgumentException(name + ": " + value + " is 
illegal.");
+        }
+    }
+
+    private static void validateInstance(String instance) {
+        if (instance == null || !INSTANCE.matcher(instance).matches()) {
+            throw new IllegalArgumentException("instance: " + instance + " is 
illegal.");
+        }
+    }
 }
diff --git 
a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImplTest.java
 
b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImplTest.java
new file mode 100644
index 0000000000..a5aea4718a
--- /dev/null
+++ 
b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/service/impl/MetricsDataServiceImplTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.warehouse.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.hertzbeat.common.entity.dto.MetricsHistoryData;
+import org.apache.hertzbeat.common.entity.dto.Value;
+import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
+import org.apache.hertzbeat.warehouse.store.realtime.RealTimeDataReader;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Test case for {@link MetricsDataServiceImpl}.
+ *
+ * <p>The history query inputs arrive from rest path variables and a query 
parameter and are
+ * interpolated into native queries by every time series storage: the range 
lands unquoted
+ * in the time predicate, while the app, metrics group, metric and instance 
land as table
+ * and column identifiers or as promql label values. The only storage that 
parses the range
+ * instead of interpolating it is questdb, and the default duckdb storage uses 
prepared
+ * statements, so validating here is what covers tdengine, influxdb, iotdb and 
victoria
+ * metrics at once.
+ */
+@ExtendWith(MockitoExtension.class)
+class MetricsDataServiceImplTest {
+
+    @Mock
+    private RealTimeDataReader realTimeDataReader;
+
+    @Mock
+    private HistoryDataReader historyDataReader;
+
+    private MetricsDataServiceImpl metricsDataService;
+
+    @BeforeEach
+    void setUp() {
+        metricsDataService = new MetricsDataServiceImpl(realTimeDataReader, 
Optional.of(historyDataReader));
+    }
+
+    @Test
+    void testHistoryRangeBreakingOutOfTheTimePredicateIsRejected() {
+        // reproduces the reported payload: the range closes `ts >= now - ?` 
and continues the statement
+        assertRejected("1s union all select ts, metric_labels, `usage` from 
`other_table` where ts>=now-1w");
+        assertRejected("1h; drop table cpu");
+        assertRejected("1h' or '1'='1");
+        assertRejected("1h)--");
+    }
+
+    @Test
+    void testMalformedHistoryRangeIsRejected() {
+        assertRejected("");
+        assertRejected("6");
+        assertRejected("hh");
+        assertRejected("-1h");
+        assertRejected("1 h");
+    }
+
+    @Test
+    void testRangesTheUiSendsAreAccepted() {
+        // the periods behind the chart buttons, plus the default applied when 
none is given
+        for (String range : List.of("1h", "6h", "1D", "1W", "4W", "12W")) {
+            when(historyDataReader.getHistoryMetricData("127.0.0.1", "linux", 
"cpu", "usage", range))
+                    .thenReturn(Map.of("", List.of(new Value("1", 1L))));
+
+            MetricsHistoryData data = metricsDataService.getMetricHistoryData(
+                    "127.0.0.1", "linux", "cpu", "usage", range, false);
+
+            assertEquals("cpu", data.getMetrics());
+        }
+    }
+
+    @Test
+    void testMissingRangeFallsBackToTheDefault() {
+        when(historyDataReader.getHistoryMetricData("127.0.0.1", "linux", 
"cpu", "usage", "6h"))
+                .thenReturn(Map.of("", List.of(new Value("1", 1L))));
+
+        metricsDataService.getMetricHistoryData("127.0.0.1", "linux", "cpu", 
"usage", null, false);
+
+        verify(historyDataReader).getHistoryMetricData("127.0.0.1", "linux", 
"cpu", "usage", "6h");
+    }
+
+    @Test
+    void testIdentifierEscapingTheQuotingIsRejected() {
+        // a backtick closes a tdengine identifier, a double quote closes a 
questdb one
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1", "linux`,(select 1) `x", "cpu", "usage", "6h", 
false));
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1", "linux", "cpu\" or \"1\"=\"1", "usage", "6h", 
false));
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1", "linux", "cpu", "usage`", "6h", false));
+        // the instance lands inside a promql label selector in victoria 
metrics
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1\",__name__=~\".*", "linux", "cpu", "usage", "6h", 
false));
+
+        verify(historyDataReader, never()).getHistoryMetricData(anyString(), 
anyString(), anyString(),
+                anyString(), anyString());
+    }
+
+    @Test
+    void testNamesUsedByTheShippedTemplatesAreAccepted() {
+        // dashes appear in template field names, an instance is usually an 
address
+        when(historyDataReader.getHistoryMetricData("[::1]:8080", "hugegraph", 
"cache", "edge-hugegraph-hits", "6h"))
+                .thenReturn(Map.of("", List.of(new Value("1", 1L))));
+
+        MetricsHistoryData data = metricsDataService.getMetricHistoryData(
+                "[::1]:8080", "hugegraph", "cache", "edge-hugegraph-hits", 
"6h", false);
+
+        assertEquals("cache", data.getMetrics());
+    }
+
+    @Test
+    void testIntervalQueriesAreValidatedTheSameWay() {
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1", "linux", "cpu", "usage", "1h; drop table cpu", 
true));
+
+        verify(historyDataReader, 
never()).getHistoryIntervalMetricData(anyString(), anyString(), anyString(),
+                anyString(), anyString());
+    }
+
+    private void assertRejected(String history) {
+        assertThrows(IllegalArgumentException.class, () -> 
metricsDataService.getMetricHistoryData(
+                "127.0.0.1", "linux", "cpu", "usage", history, false), 
"expected rejection of: " + history);
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to