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

Aias00 pushed a commit to branch fix/008-questdb-sql-injection
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 0b2476e75189eaf86c986c0a3cfa511efd81c775
Author: liuhy <[email protected]>
AuthorDate: Tue Jul 28 07:14:33 2026 -0700

    [fix] prevent SQL injection in QuestDB history queries
    
    QuestDB history queries build their SQL with String.format, interpolating
    the metric (column), table, and instance values straight into the
    templates. Those values trace back to rest path variables
    (/api/monitor/{instance}/metric/{metricFull}), so an attacker-controlled
    instance or metricFull could break out of the templated SQL.
    
    Two gaps:
    
    1. Identifiers (metric column, table name) were placed inside double
       quotes with no charset check. A path value carrying a double quote
       or other SQL metacharacter could escape the identifier and inject.
    2. The instance string literal was escaped with
       replace("'", "\\'") which is not a valid QuestDB escape
       (QuestDB/ANSI doubles the quote), so a stored metric_labels value
       containing a single quote stayed injectable.
    
    Fix:
    - validateIdentifier() rejects metric/table values outside
      ^[A-Za-z0-9_-]+$ before they reach String.format, failing closed.
    - escapeStringLiteral() doubles single quotes (the QuestDB string
      literal escape) for the instance value in the WHERE clause.
    
    QuestDB's HTTP /exec endpoint does not support bind parameters, so the
    read path is validated rather than parameterized.
    
    Co-Authored-By: Claude <[email protected]>
---
 .../history/tsdb/questdb/QuestdbDataStorage.java   | 45 +++++++++++++++++++++-
 1 file changed, 44 insertions(+), 1 deletion(-)

diff --git 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
index 93f5b017f2..c94ede7ab1 100644
--- 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
+++ 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java
@@ -32,6 +32,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSocketFactory;
@@ -188,6 +189,8 @@ public class QuestdbDataStorage extends 
AbstractHistoryDataStorage {
     public Map<String, List<Value>> getHistoryMetricData(String instance, 
String app, String metrics, String metric, String history) {
         String table = this.generateTable(app, metrics, instance);
         String dateAdd = getDateAdd(history);
+        validateIdentifier(metric, "metric");
+        validateIdentifier(table, "table");
         String selectSql = String.format(QUERY_HISTORY_SQL, metric, table, 
dateAdd);
         Map<String, List<Value>> instanceValueMap = new HashMap<>(8);
         try {
@@ -228,6 +231,8 @@ public class QuestdbDataStorage extends 
AbstractHistoryDataStorage {
     public Map<String, List<Value>> getHistoryIntervalMetricData(String 
instance, String app, String metrics, String metric, String history) {
         String table = this.generateTable(app, metrics, instance);
         String dateAdd = getDateAdd(history);
+        validateIdentifier(metric, "metric");
+        validateIdentifier(table, "table");
         Map<String, List<Value>> instanceValueMap = new HashMap<>(8);
         Set<String> instances = new HashSet<>(8);
         // query all metric_labels
@@ -247,7 +252,7 @@ public class QuestdbDataStorage extends 
AbstractHistoryDataStorage {
                 instances.add("");
             }
             for (String instanceValue : instances) {
-                String selectSql = 
String.format(QUERY_HISTORY_INTERVAL_WITH_INSTANCE_SQL, metric, metric, metric, 
metric, table, instanceValue.replace("'", "\\'"), dateAdd);
+                String selectSql = 
String.format(QUERY_HISTORY_INTERVAL_WITH_INSTANCE_SQL, metric, metric, metric, 
metric, table, escapeStringLiteral(instanceValue), dateAdd);
                 Map<String, Object> selectResult = executeQuery(selectSql);
                 if (selectResult == null || 
!selectResult.containsKey("dataset")) {
                     continue;
@@ -365,6 +370,44 @@ public class QuestdbDataStorage extends 
AbstractHistoryDataStorage {
         return app + "_" + metrics + "_" + instance;
     }
 
+    /**
+     * Identifier allowlist for QuestDB double-quoted identifiers 
(table/column names).
+     * Keeping these to a safe charset prevents breaking out of the surrounding
+     * {@code "..."} quotes in the templated SQL, since none of the allowed
+     * characters can terminate the identifier or introduce SQL syntax.
+     */
+    private static final Pattern IDENTIFIER = 
Pattern.compile("^[A-Za-z0-9_-]+$");
+
+    /**
+     * Fail closed when an interpolated SQL identifier carries anything 
outside the
+     * safe charset. The {@code metric} and {@code table} values originate 
from rest
+     * path variables, so without this check they could inject into the query.
+     *
+     * @param name  identifier to validate
+     * @param label human-readable field name for the error message
+     */
+    private static void validateIdentifier(String name, String label) {
+        if (name == null || !IDENTIFIER.matcher(name).matches()) {
+            throw new IllegalArgumentException("QuestDB query rejected: 
invalid " + label + " identifier");
+        }
+    }
+
+    /**
+     * Escape a value interpolated into a single-quoted QuestDB string literal.
+     * QuestDB follows the ANSI rule: a single quote is escaped by doubling it
+     * ({@code '} -> {@code ''}). The previous {@code replace("'", "\\'")} was 
not
+     * a valid QuestDB escape and left the literal injectable.
+     *
+     * @param value raw value, may be null
+     * @return value safe to place inside {@code '...'}
+     */
+    private static String escapeStringLiteral(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("'", "''");
+    }
+
     private String parseDoubleValue(String value) {
         return (new BigDecimal(value)).setScale(4, 
RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
     }


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

Reply via email to