mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(querybuilder): allow row-generator table functions
The table-function rule refuses everything, which costs a false positive
on queries that use numbers() or generateSeries() to build a dense
interval axis to join a sparse series against. Neither reads through
anything: they compute their rows from their arguments, open no file or
socket, reach no other host, and name no table, database or dictionary.
Everything else stays refused. Most table functions read through
something, and merge('system', '.*'), remote() and cluster() reach the
internal databases without producing a TableIdentifier for the database
rule to catch, so this rule is all that sees them. generateRandom is pure
but streams rows its arguments do not bound, so it stays out too.
Arguments are visited before the table function itself, which is what
keeps an allowed generator from being usable as a wrapper to smuggle a
read through.
* test(querybuilder): address review on the generator allow list
Names the allowed table functions in the rejection error, collapses the
added comments, and covers the generators in JOIN, CTE, subquery and
UNION position alongside the reads they must not be usable to smuggle.
* refactor(querybuilder): derive the allowed table function message from the map
Values the map by the spelling to name back to the caller, so the message
comes off the same data the lookup uses and cannot drift from it. Drops
the test that existed only to catch that drift.
114 lines
4.8 KiB
Go
114 lines
4.8 KiB
Go
package querybuilder
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
|
|
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
|
"github.com/SigNoz/signoz/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
CodeClickHouseSQLParserPanic = errors.MustNewCode("clickhouse_sql_parser_panic")
|
|
CodeClickHouseSQLUnparseable = errors.MustNewCode("clickhouse_sql_unparseable")
|
|
CodeClickHouseSQLNotSingleStatement = errors.MustNewCode("clickhouse_sql_not_single_statement")
|
|
CodeClickHouseSQLNotSelect = errors.MustNewCode("clickhouse_sql_not_select")
|
|
CodeClickHouseSQLTableFunction = errors.MustNewCode("clickhouse_sql_table_function")
|
|
CodeClickHouseSQLInternalDatabase = errors.MustNewCode("clickhouse_sql_internal_database")
|
|
CodeClickHouseSQLReadonlyOverride = errors.MustNewCode("clickhouse_sql_readonly_override")
|
|
)
|
|
|
|
// internalDatabases hold server metadata, credentials and grants rather than telemetry.
|
|
var internalDatabases = map[string]struct{}{
|
|
"system": {},
|
|
"information_schema": {},
|
|
}
|
|
|
|
// generatorTableFunctions compute their rows from their arguments alone. They open no file or socket, reach no other host, and name no table, database or dictionary, so none of them can read through anything the rules here exist to protect. Can be used to build a dense axis to join a sparse series against. Every other table function is refused.
|
|
//
|
|
// Keyed by the lowercased name so that matching is case-insensitive, valued by the spelling to name it back to the caller.
|
|
//
|
|
// TODO(@therealpandey): take a deployment level allow list on top of this, so an operator can permit more without a release.
|
|
var generatorTableFunctions = map[string]string{
|
|
"numbers": "numbers",
|
|
"numbers_mt": "numbers_mt",
|
|
"zeros": "zeros",
|
|
"zeros_mt": "zeros_mt",
|
|
"generateseries": "generateSeries",
|
|
"generate_series": "generate_series",
|
|
}
|
|
|
|
var generatorTableFunctionsMessage = "allowed table functions are " + strings.Join(slices.Sorted(maps.Values(generatorTableFunctions)), ", ")
|
|
|
|
// The parser's grammar has gaps against SQL that ClickHouse itself accepts.
|
|
func ErrIfStatementIsNotValid(query string) (err error) {
|
|
defer func() {
|
|
// The parser has a history of panicking on malformed input rather than returning an error.
|
|
if recovered := recover(); recovered != nil {
|
|
err = errors.NewInvalidInputf(CodeClickHouseSQLParserPanic, "invalid ClickHouse SQL (recovered): %v", recovered)
|
|
}
|
|
}()
|
|
|
|
stmts, parseErr := chparser.NewParser(query).ParseStmts()
|
|
if parseErr != nil {
|
|
// Wrapped rather than formatted in, so that callers can recover the parser's *ParseError and read the position off it.
|
|
return errors.WrapInvalidInputf(parseErr, CodeClickHouseSQLUnparseable, "invalid ClickHouse SQL: %s", parseErr.Error())
|
|
}
|
|
|
|
if len(stmts) != 1 {
|
|
return errors.NewInvalidInputf(CodeClickHouseSQLNotSingleStatement, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
|
|
}
|
|
|
|
selectQuery, ok := stmts[0].(*chparser.SelectQuery)
|
|
if !ok {
|
|
return errors.NewInvalidInputf(CodeClickHouseSQLNotSelect, "only SELECT statements are allowed in ClickHouse SQL queries")
|
|
}
|
|
|
|
visitor := &chparser.DefaultASTVisitor{Visit: func(node chparser.Expr) error {
|
|
switch expr := node.(type) {
|
|
case *chparser.TableFunctionExpr:
|
|
// Source table functions remain usable in ClickHouse read-only mode. Arguments are
|
|
// visited before this, so a read smuggled into one is already refused by the time
|
|
// an allowed generator gets here.
|
|
name := chparser.Format(expr.Name)
|
|
if _, ok := generatorTableFunctions[strings.ToLower(name)]; ok {
|
|
return nil
|
|
}
|
|
|
|
return errors.
|
|
NewInvalidInputf(CodeClickHouseSQLTableFunction, "ClickHouse table functions are not allowed in SQL queries: %s", name).
|
|
WithAdditional(generatorTableFunctionsMessage)
|
|
|
|
case *chparser.TableIdentifier:
|
|
// Reading these is unaffected by ClickHouse read-only mode.
|
|
if expr.Database == nil {
|
|
return nil
|
|
}
|
|
|
|
if _, ok := internalDatabases[strings.ToLower(expr.Database.Name)]; ok {
|
|
return errors.NewInvalidInputf(CodeClickHouseSQLInternalDatabase, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
|
|
}
|
|
|
|
case *chparser.SettingExpr:
|
|
// A query-level setting takes precedence over the context setting.
|
|
if strings.EqualFold(expr.Name.Name, "readonly") {
|
|
return errors.NewInvalidInputf(CodeClickHouseSQLReadonlyOverride, "the ClickHouse readonly setting cannot be overridden")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}}
|
|
|
|
return selectQuery.Accept(visitor)
|
|
}
|
|
|
|
// TODO(@therealpandey): remove this and move to ErrIfStatementIsNotValid.
|
|
func LogIfStatementIsNotValid(ctx context.Context, logger *slog.Logger, query string) {
|
|
if err := ErrIfStatementIsNotValid(query); err != nil {
|
|
logger.WarnContext(ctx, "clickhouse sql is not valid", errors.Attr(err), slog.String("query", query))
|
|
}
|
|
}
|