[fix] prevent SQL injection in QuestDB history queries - #4259
Conversation
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>
zqr10159
left a comment
There was a problem hiding this comment.
Reviewed against the 1.9.0 pre-release QuestDB history-query finding. Identifier allowlisting and ANSI string-literal escaping close the reported injection paths; the current CI checks pass.
|
Thanks for tracking this down — I reproduced the issue and the direction is right. metric and table are interpolated into "%s" with no charset check, a " closes the identifier, and any payload without a . gets past the controller's three-segment split. replace("'", "\'") is genuinely wrong for QuestDB (ANSI quote doubling, backslash is not an escape). The endpoint is reachable by the guest role (sureness.yml:31), so this is worth fixing. A few things I'd like to resolve before this lands.
Two options: extend this PR to tdengine/influxdb/iotdb, or do the identifier and range validation once in MetricsDataServiceImpl — where all four storages converge — and keep only escapeStringLiteral() here, since quote escaping is the one genuinely dialect-specific piece that can't move up. Either way, could we open a tracking issue for the remaining storages? I'd rather not have this marked "fixed" while three of them stay open.
if (instance.contains(".") || instance.contains(":") || instance.contains("[")) An instance like a]b skips the branch entirely, keeps the ], and gets rejected by the new check. Dropping the if and folding unconditionally (or replacing anything outside the charset) makes the table slot non-injectable by construction and removes the need for validateIdentifier(table, ...). Note that a full-charset normalization would change existing table names, so that part needs a compatibility call. Keeping metric as reject-only is correct — it's a column name and must match exactly.
Minor:
|
Summary
Fixes SQL injection in the QuestDB history-data query path.
Vulnerability
QuestdbDataStoragebuilds all four history-query SQL templates withString.format, interpolatingmetric(a column name),table, andinstancedirectly into the SQL. These values trace back to REST path variables:So
instanceandmetricFullare attacker-controlled. Two concrete gaps:metric,table) are placed inside double-quoted identifiers with no charset check. A path value carrying a"or other SQL metacharacter can break out of the identifier.instancein theWHERE metric_labels = '...'clause is escaped withreplace("'", "\\'"). QuestDB does not treat backslash as an escape (it follows the ANSI rule of doubling the quote), so a storedmetric_labelsvalue containing a'stayed injectable.Fix
QuestDB's HTTP
/execendpoint does not support bind parameters, so the read path is hardened with validation + correct escaping:validateIdentifier(name, label)— rejectsmetricandtablevalues outside^[A-Za-z0-9_-]+$before they reachString.format. Fails closed withIllegalArgumentException. None of the allowed characters can terminate the surrounding"..."identifier or introduce SQL syntax (a--inside a quoted identifier is harmless).escapeStringLiteral(value)— replaces the broken backslash escape with the correct QuestDB/ANSI escape: doubling the single quote ('→'').Verification
mvn -pl hertzbeat-warehouse -am compile✅ (JDK 25)mvn -pl hertzbeat-warehouse -am checkstyle:check✅instance=127.0.0.1:8080→ tablelinux_cpu_127_0_0_1_8080(matches allowlist); hyphenated hostnames also pass.No QuestDB unit tests exist in the module today, so no existing test coverage was extended here.
🤖 Generated with Claude Code