diff --git a/internal/observabilityproxy/logsparams.go b/internal/observabilityproxy/logsparams.go index cca53e62d..578714d6f 100644 --- a/internal/observabilityproxy/logsparams.go +++ b/internal/observabilityproxy/logsparams.go @@ -24,6 +24,8 @@ import ( "strconv" "strings" "time" + "unicode" + "unicode/utf8" ) // LogsLimits bound what one logs request may make vlselect do. @@ -66,6 +68,13 @@ func BuildLogsParams(endpoint string, in url.Values, namespace string, now time. if strings.Contains(strings.ToLower(query), "ignore_global_time_filter") { return nil, errorf(http.StatusBadRequest, "ignore_global_time_filter is not allowed") } + // The substring check alone is bypassable: VictoriaLogs unquotes a + // quoted option name and joins words split by a comment. No option is + // needed by a tenant (the others tune concurrency or the time filter), + // so any options(...) block is refused. + if hasOptionsBlock(query) { + return nil, errorf(http.StatusBadRequest, "query options are not allowed") + } out := url.Values{} out.Set("query", query) out.Set("extra_stream_filters", "{kubernetes_namespace_name="+strconv.Quote(namespace)+"}") @@ -301,6 +310,42 @@ func parseTime(s string, now time.Time) (time.Time, error) { return time.Time{}, fmt.Errorf("unsupported time %q", s) } +// hasOptionsBlock reports whether the options keyword is followed by "(", +// skipping whitespace and "#" comments the way the LogsQL lexer does. A +// quoted "options" is never the keyword, but matching it too only refuses +// a search for that literal phrase. +func hasOptionsBlock(query string) bool { + lower := strings.ToLower(query) + for i := strings.Index(lower, "options"); i >= 0; { + rest := lower[i+len("options"):] + for rest != "" { + r, size := utf8.DecodeRuneInString(rest) + switch { + case unicode.IsSpace(r): + rest = rest[size:] + continue + case r == '#': + if n := strings.IndexByte(rest, '\n'); n >= 0 { + rest = rest[n+1:] + } else { + rest = "" + } + continue + } + break + } + if strings.HasPrefix(rest, "(") { + return true + } + next := strings.Index(lower[i+1:], "options") + if next < 0 { + return false + } + i += next + 1 + } + return false +} + func isDigits(s string) bool { if s == "" { return false diff --git a/internal/observabilityproxy/logsparams_test.go b/internal/observabilityproxy/logsparams_test.go index 1ebc03fd9..e42a73012 100644 --- a/internal/observabilityproxy/logsparams_test.go +++ b/internal/observabilityproxy/logsparams_test.go @@ -120,6 +120,9 @@ func TestBuildLogsParamsRejects(t *testing.T) { {"ignore_global_time_filter", "query", url.Values{"query": {`* options(ignore_global_time_filter=true)`}}, http.StatusBadRequest}, {"ignore_global_time_filter in subquery", "hits", url.Values{"query": {`* | union (options(ignore_global_time_filter=true) error)`}}, http.StatusBadRequest}, {"ignore_global_time_filter on tail", "tail", url.Values{"query": {`options(IGNORE_GLOBAL_TIME_FILTER=true) *`}}, http.StatusBadRequest}, + {"options with a quoted escaped name", "query", url.Values{"query": {`options("ignore_global_time_filte\x72"=true) *`}}, http.StatusBadRequest}, + {"options with a comment inside the name", "query", url.Values{"query": {"options(ignore_global#c\n_time_filter=true) *"}}, http.StatusBadRequest}, + {"options after a comment and unicode space", "tail", url.Values{"query": {"OPTIONS #c\n\u00a0(concurrency=1000) *"}}, http.StatusBadRequest}, {"inverted range", "query", url.Values{"start": {"2026-11-04T11:00:00Z"}, "end": {"2026-11-04T10:00:00Z"}}, http.StatusBadRequest}, {"inverted range on tail", "tail", url.Values{"start": {"2026-11-04T11:00:00Z"}, "end": {"2026-11-04T10:00:00Z"}}, http.StatusBadRequest}, {"bad start", "query", url.Values{"start": {"yesterday"}}, http.StatusBadRequest},