Skip to content

[fix] validate sql reaching the alert query executor - #4275

Draft
Duansg wants to merge 9 commits into
apache:masterfrom
Duansg:fix-alert-preview-sql-validation
Draft

[fix] validate sql reaching the alert query executor#4275
Duansg wants to merge 9 commits into
apache:masterfrom
Duansg:fix-alert-preview-sql-validation

Conversation

@Duansg

@Duansg Duansg commented Jul 29, 2026

Copy link
Copy Markdown
Member

What's changed?

Apply read-only validation at the point where SQL reaches the executor, and fix the validator's handling of multiple statements.

Checklist

  • I have read the Contributing Guide
  • I have written the necessary doc or comment.
  • I have added the necessary unit tests and all cases have passed.

Add or update API

  • I have added the necessary e2e tests and all cases have passed.

@zqr10159

Copy link
Copy Markdown
Member

This is useful executor-level read-only validation, but it does not close the complete alert-query exposure. Low-privilege callers can still issue broad telemetry reads, expensive SELECTs, and raw PromQL; SELECT-only parsing also permits unions/subqueries across available tables. Please add an authorization/ownership boundary for preview and authoring, scope readable sources/tables, and enforce time/range/cost limits for both SQL and PromQL. I would treat the current patch as one defense-in-depth layer, not a complete remediation.

Duansg and others added 8 commits August 9, 2026 17:37
…elling

The read only check only covered the sql("...") spelling, so an expression that
carried a statement as promql("...") still reached the executor unvalidated
whenever the caller named a sql datasource. The preview endpoint takes that
datasource straight from the path, so the spelling could not decide anything.
Guard QueryExecutor.execute instead: every route into the executor is covered,
including the ones added later, and the raw log query path shares the same seam
rather than repeating the check.

Fix two holes in the validator while it is the only thing standing between an
alert rule and the database credentials. CCJSqlParserUtil.parse returns the
first statement and discards the rest, so "select 1; drop table x" validated as
a plain select while the caller still handed the whole string over. And
"select * into backup from cpu" parses as a select but writes.

Read only mode no longer rejects what JSqlParser cannot parse. The only sql
executor talks to GreptimeDB, whose range query syntax the parser does not
cover although it is an ordinary read, and failing those rules would trade a
working feature for nothing. The two properties this mode has to guarantee, one
statement and read only, are established by scanning outside literals and
comments instead, which no dialect can confuse. The whitelisting mode keeps
failing closed, because enumerating table names needs the parse tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read only check looked at the outermost node of the parse tree, which
proves nothing about the rest of it. JSqlParser reports a plain select as the
outermost node of all three of these, and each carries a write:

  WITH x AS (DELETE FROM cpu RETURNING *) SELECT * FROM x
  SELECT * INTO backup FROM cpu UNION SELECT * FROM cpu
  SELECT * FROM (SELECT * INTO backup FROM cpu) t

Walk the whole tree instead. A walk that ends in an exception rejects too: a
data modifying cte makes JSqlParser's own finder cast a ParenthesedDelete to a
ParenthesedSelect, which used to escape as a 500 rather than a rejection.

The tree is only available when the parser can read the dialect, and the whole
point of read only mode is that it often cannot, so the statement scan now also
rejects a string carrying a word that only a write contains. That is what holds
for a nested write in a GreptimeDB range query. It matches whole words outside
literals, so delete_count and truncate(value, 2) keep working, and it lists no
word that doubles as an ordinary function.

With writes caught wherever they sit, a cte no longer has to parse to be
accepted, so range queries inside a WITH work now.

The whitelisting mode had the same hole from the other side: it says which
tables a statement may touch, so "select * into backup from hertzbeat_logs"
named only allowed tables and passed. It walks the tree now too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Duansg <siguoduan@gmail.com>
Skipping the literal closed one hole by opening its mirror. A dialect that has
dollar quoting hides a stacked statement behind the comment marker in

  SELECT $$--$$; DROP TABLE cpu

and skipping the literal is what catches that. But a dialect that does not have
dollar quoting means the semicolon in

  SELECT 1 $$;DROP TABLE cpu$$

separates statements for real, and there the skip is the thing doing the hiding:
that string was rejected before the literal was understood and accepted after.

Refusing needs no assumption in either direction. A dialect with dollar quoting
is refused; one without was going to fail on the syntax anyway. No read of a
metric table spells anything this way, and a dollar sign that opens no literal
stays an ordinary character, so $1 and a$b and '$5' still parse as reads.

That the mirror case was reachable at all came from leaning on the parser: it
reads $t$...$t$ as two statements but $$...$$ as one, and the whole reason this
scan exists is that the parser cannot be relied on for the dialect in front of
it.

A dollar sign now also ends a word, so a literal opening straight after an
identifier is still seen rather than swallowed into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Duansg
Duansg marked this pull request as draft August 9, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants