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]
