Compare commits

...
Author SHA1 Message Date
aias00 c2755a37b1 Merge branch 'master' into fix/008-questdb-sql-injection 2026-08-17 13:50:00 +08:00
Duansg 3a73a34daf Merge branch 'master' into fix/008-questdb-sql-injection 2026-08-10 00:01:14 +08:00
shown 358fbc0c28 Merge branch 'master' into fix/008-questdb-sql-injection 2026-07-29 22:40:53 +08:00
liuhyandClaude 0b2476e751 [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 <noreply@anthropic.com>
2026-07-28 07:14:33 -07:00
@@ -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();
}