fix(observability-proxy): refuse every LogsQL options block

A substring match on ignore_global_time_filter does not see the option
once it is written as a quoted name with an escape, or split by a
comment: the LogsQL lexer unquotes option names and joins words across
a comment, so the option still applies and lifts the time window the
proxy enforces. None of the query options is needed by a tenant, so
the options keyword followed by a parenthesis is refused outright,
skipping whitespace and comments exactly as the lexer does.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
This commit is contained in:
ZverGuy committed 2026-11-09 15:48:57 +07:00
1 parent 8b0ac342b5
commit cbca8ac3c9
2 files changed
+48

No files matched your search

+45
View File
@@ -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
@@ -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},