Files
zeekayandHanzo Dev 52f2231987 security: the older builders quote the value beside the key but never the key
The v3/v4 builders are live — querier.go wires tracesV4.PrepareTracesQuery — and
they carry the same bug as the v5 statement builders, one layer down and one
position wider. Three caller values reached ClickHouse raw:

  - the ORDER BY DIRECTION. Direction is a bare string off the request body and
    every builder interpolated it beside the column, so
    "asc,(select count() from system.tables)" was an entire extra ordering term.
    It needed no quote and no backtick to get there — the cheapest of the three.
  - the ORDER BY / GROUP BY / alias COLUMN, in backticks it can close.
  - the ATTRIBUTE KEY, in the single quotes of the map and JSON accessors. That
    one lands in the WHERE clause, so it is not an ordering oracle but a
    predicate the caller writes: mapContains(attributes_string, 'a'),(select 1))
    or ('1'='1') parses and runs.

The asymmetry is stark here. On the very lines that build a filter, the VALUE
goes through utils.QuoteEscapedString and the KEY beside it goes in bare. And
the allowlist that appears to gate the column — tagLookup — is built out of the
caller's own GroupBy, so a name repeated in both fields validates itself.

utils.AddBackTickToFormatTag is the pass-through underneath, and it declined to
quote in two ways: a name with no "." and no "-" came back BARE, and a name
already wrapped in backticks came back AS IS, which let a caller supply its own
quoting. Neither branch escaped a backtick. It now returns bare only for what it
was assuming — a plain identifier — and quotes and escapes everything else.

Direction.Keyword() is the guard for the direction, on the value rather than at
each of the sixteen sites that interpolate it. It folds case to compare because
callers send both, and returns the ORIGINAL on a match, which is safe precisely
because the match succeeded: a string whose lowercase is "asc" or "desc" is
those letters and nothing else. Anything else renders as empty, which is what an
absent direction already rendered as.

Identifiers and literals go through the same querybuilder.QuoteIdent and
EscapeLiteral the v5 builders now use. A real key takes the path it always did,
so the golden SQL in every one of these packages is unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 21:49:08 -07:00

414 lines
11 KiB
Go

package utils
import (
"fmt"
"math"
"reflect"
"regexp"
"strconv"
"strings"
"log/slog"
"github.com/hanzoai/o11y/pkg/query-service/constants"
"github.com/hanzoai/o11y/pkg/query-service/metrics"
v3 "github.com/hanzoai/o11y/pkg/query-service/model/v3"
"github.com/hanzoai/o11y/pkg/querybuilder"
)
// ValidateAndCastValue validates and casts the value of a key to the corresponding data type of the key
func ValidateAndCastValue(v interface{}, dataType v3.AttributeKeyDataType) (interface{}, error) {
// get the actual value if it's a pointer
v = getPointerValue(v)
switch dataType {
case v3.AttributeKeyDataTypeString:
switch x := v.(type) {
case string, int, int64, float32, float64, bool:
return fmt.Sprintf("%v", x), nil
case []interface{}:
for i, val := range x {
// if val is not string and it is int, int64, float, bool, convert it to string
if _, ok := val.(string); ok {
continue
} else if _, ok := val.(int); ok {
x[i] = fmt.Sprintf("%v", val)
} else if _, ok := val.(int64); ok {
x[i] = fmt.Sprintf("%v", val)
} else if _, ok := val.(float32); ok {
x[i] = fmt.Sprintf("%v", val)
} else if _, ok := val.(float64); ok {
x[i] = fmt.Sprintf("%v", val)
} else if _, ok := val.(bool); ok {
x[i] = fmt.Sprintf("%v", val)
} else {
return nil, fmt.Errorf("invalid data type, expected string, got %v", reflect.TypeOf(val))
}
}
return x, nil
case []string:
return x, nil
default:
return nil, fmt.Errorf("invalid data type, expected string, got %v", reflect.TypeOf(v))
}
case v3.AttributeKeyDataTypeBool:
switch x := v.(type) {
case []interface{}:
for i, val := range x {
if _, ok := val.(string); ok {
boolean, err := strconv.ParseBool(val.(string))
if err != nil {
return nil, fmt.Errorf("invalid data type, expected bool, got %v", reflect.TypeOf(val))
}
x[i] = boolean
} else if _, ok := val.(bool); !ok {
return nil, fmt.Errorf("invalid data type, expected bool, got %v", reflect.TypeOf(val))
} else {
x[i] = val.(bool)
}
}
return x, nil
case bool:
return x, nil
case string:
boolean, err := strconv.ParseBool(x)
if err != nil {
return nil, fmt.Errorf("invalid data type, expected bool, got %v", reflect.TypeOf(v))
}
return boolean, nil
default:
return nil, fmt.Errorf("invalid data type, expected bool, got %v", reflect.TypeOf(v))
}
case v3.AttributeKeyDataTypeInt64:
switch x := v.(type) {
case []interface{}:
for i, val := range x {
if _, ok := val.(string); ok {
int64val, err := strconv.ParseInt(val.(string), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid data type, expected int, got %v", reflect.TypeOf(val))
}
x[i] = int64val
} else if _, ok := val.(int); ok {
x[i] = int64(val.(int))
} else if _, ok := val.(int64); !ok {
return nil, fmt.Errorf("invalid data type, expected int, got %v", reflect.TypeOf(val))
} else {
x[i] = val.(int64)
}
}
return x, nil
case int, int64:
return x, nil
case float32:
return int64(x), nil
case float64:
return int64(x), nil
case string:
int64val, err := strconv.ParseInt(x, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid data type, expected int, got %v", reflect.TypeOf(v))
}
return int64val, nil
default:
return nil, fmt.Errorf("invalid data type, expected int, got %v", reflect.TypeOf(v))
}
case v3.AttributeKeyDataTypeFloat64:
switch x := v.(type) {
case []interface{}:
for i, val := range x {
if _, ok := val.(string); ok {
float64val, err := strconv.ParseFloat(val.(string), 64)
if err != nil {
return nil, fmt.Errorf("invalid data type, expected float, got %v", reflect.TypeOf(val))
}
x[i] = float64val
} else if _, ok := val.(float32); ok {
x[i] = float64(val.(float32))
} else if _, ok := val.(int); ok {
x[i] = float64(val.(int))
} else if _, ok := val.(int64); ok {
x[i] = float64(val.(int64))
} else if _, ok := val.(float64); !ok {
return nil, fmt.Errorf("invalid data type, expected float, got %v", reflect.TypeOf(val))
} else {
x[i] = val.(float64)
}
}
return x, nil
case float32, float64:
return x, nil
case string:
float64val, err := strconv.ParseFloat(x, 64)
if err != nil {
return nil, fmt.Errorf("invalid data type, expected float, got %v", reflect.TypeOf(v))
}
return float64val, nil
case int:
return float64(x), nil
case int64:
return float64(x), nil
default:
return nil, fmt.Errorf("invalid data type, expected float, got %v", reflect.TypeOf(v))
}
default:
return nil, fmt.Errorf("invalid data type, expected float, bool, int, string or []interface{} but got %v", dataType)
}
}
func QuoteEscapedString(str string) string {
// https://datastore.com/docs/en/sql-reference/syntax#string
str = strings.ReplaceAll(str, `\`, `\\`)
str = strings.ReplaceAll(str, `'`, `\'`)
return str
}
func QuoteEscapedStringForContains(str string, isIndex bool) string {
// https: //datastore.com/docs/en/sql-reference/functions/string-search-functions#like
str = QuoteEscapedString(str)
// we are adding this because if a string contains quote `"` it will be stored as \" in datastore
// to query that using like our query should be \\\\"
if isIndex {
// isIndex is true means that the extra slash is present
// [\"a\",\"b\",\"sdf\"]
str = strings.ReplaceAll(str, `"`, `\\\\"`)
}
str = strings.ReplaceAll(str, `%`, `\%`)
str = strings.ReplaceAll(str, `_`, `\_`)
return str
}
// DatastoreFormattedValue formats the value to be used in datastore query
func DatastoreFormattedValue(v interface{}) string {
// if it's pointer convert it to a value
v = getPointerValue(v)
switch x := v.(type) {
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
return fmt.Sprintf("%d", x)
case float32, float64:
return fmt.Sprintf("%f", x)
case string:
return fmt.Sprintf("'%s'", QuoteEscapedString(x))
case bool:
return fmt.Sprintf("%v", x)
case []interface{}:
if len(x) == 0 {
return "[]"
}
switch x[0].(type) {
case string:
str := "["
for idx, sVal := range x {
str += fmt.Sprintf("'%s'", QuoteEscapedString(sVal.(string)))
if idx != len(x)-1 {
str += ","
}
}
str += "]"
return str
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64, float32, float64, bool:
return strings.Join(strings.Fields(fmt.Sprint(x)), ",")
default:
slog.Error("invalid type for formatted value", "type", reflect.TypeOf(x[0]))
return "[]"
}
case []string:
if len(x) == 0 {
return "[]"
}
str := "["
for idx, sVal := range x {
str += fmt.Sprintf("'%s'", QuoteEscapedString(sVal))
if idx != len(x)-1 {
str += ","
}
}
str += "]"
return str
default:
slog.Error("invalid type for formatted value", "type", reflect.TypeOf(x))
return ""
}
}
func DatastoreFormattedMetricNames(v interface{}) string {
if name, ok := v.(string); ok {
transitionedMetrics := metrics.GetTransitionedMetric(name, !constants.IsDotMetricsEnabled)
if transitionedMetrics != name {
return DatastoreFormattedValue([]interface{}{transitionedMetrics})
} else {
return DatastoreFormattedValue([]interface{}{name})
}
}
return DatastoreFormattedValue(v)
}
// plainTag is a tag name that needs no quoting at all: the datastore reads it as
// a bare identifier and it carries nothing that could end one.
var plainTag = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// AddBackTickToFormatTag renders a tag name for an identifier position — the
// SELECT alias, the GROUP BY term and the ORDER BY term of the metrics builders,
// which all call it so the three agree.
//
// It used to DECLINE TO QUOTE in two cases, and a tag name is caller text:
//
// - a name with no "." and no "-" was returned BARE, so "a,(select 1)" was
// concatenated into the identifier position verbatim;
// - a name already wrapped in backticks was returned AS IS, so a caller could
// hand it its own quoting and close it wherever it liked.
//
// Neither branch escaped a backtick either. Now the bare return is guarded by
// what it was assuming — that the name IS a plain identifier — and everything
// else is quoted AND escaped. A real tag takes the same path it always did, so
// the SQL for one is unchanged.
func AddBackTickToFormatTag(str string) string {
if plainTag.MatchString(str) {
return str
}
return querybuilder.QuoteIdent(str)
}
func AddBackTickToFormatTags(inputs ...string) []string {
result := make([]string, len(inputs))
for i, str := range inputs {
result[i] = AddBackTickToFormatTag(str)
}
return result
}
func getPointerValue(v interface{}) interface{} {
switch x := v.(type) {
case *uint8:
return *x
case *uint16:
return *x
case *uint32:
return *x
case *uint64:
return *x
case *int:
return *x
case *int8:
return *x
case *int16:
return *x
case *int32:
return *x
case *int64:
return *x
case *float32:
return *x
case *float64:
return *x
case *string:
return *x
case *bool:
return *x
case []interface{}:
values := []interface{}{}
for _, val := range x {
values = append(values, getPointerValue(val))
}
return values
default:
return v
}
}
func GetDatastoreColumnName(typeName string, dataType, field string) string {
if typeName == string(v3.AttributeKeyTypeTag) {
typeName = constants.Attributes
}
if typeName != string(v3.AttributeKeyTypeResource) && len(typeName) > 0 {
typeName = typeName[:len(typeName)-1]
}
// if name contains . replace it with `$$`
field = strings.ReplaceAll(field, ".", "$$")
colName := fmt.Sprintf("`%s_%s_%s`", strings.ToLower(typeName), strings.ToLower(dataType), field)
return colName
}
func GetDatastoreColumnNameV2(typeName string, dataType, field string) string {
if typeName == string(v3.AttributeKeyTypeTag) {
typeName = constants.Attributes
}
if typeName != string(v3.AttributeKeyTypeResource) && len(typeName) > 0 {
typeName = typeName[:len(typeName)-1]
}
dataType = strings.ToLower(dataType)
if dataType == "int64" || dataType == "float64" {
dataType = "number"
}
// if name contains . replace it with `$$`
field = strings.ReplaceAll(field, ".", "$$")
colName := fmt.Sprintf("%s_%s_%s", strings.ToLower(typeName), dataType, field)
return colName
}
// GetEpochNanoSecs takes epoch and returns it in ns
func GetEpochNanoSecs(epoch int64) int64 {
temp := epoch
count := 0
if epoch == 0 {
count = 1
} else {
for epoch != 0 {
epoch /= 10
count++
}
}
return temp * int64(math.Pow(10, float64(19-count)))
}
func NormalizeMap(data map[string]uint64) map[string]float64 {
if len(data) == 0 {
return nil
}
var minVal, maxVal uint64
first := true
for _, v := range data {
if first {
minVal, maxVal = v, v
first = false
} else {
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
}
}
// If all values are the same, avoid division by zero
if minVal == maxVal {
normalized := make(map[string]float64)
for k := range data {
normalized[k] = 1.0 // or 0.0, depending on the convention
}
return normalized
}
// Normalize the values using min-max normalization
normalized := make(map[string]float64)
for k, v := range data {
normalized[k] = float64(v-minVal) / float64(maxVal-minVal)
}
return normalized
}