diff --git a/README.md b/README.md index 9780d65b..4c15fffe 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,30 @@ prom-label-proxy \ `prom-label-proxy` will enforce the `tenant=~"prometheus|alertmanager"` label selector in all requests. +You can match the label value using a regular expression with the `-regex-match` option. For example: + +``` +prom-label-proxy \ + -label-value '^foo-.+$' \ + -label namespace \ + -upstream http://demo.do.prometheus.io:9090 \ + -insecure-listen-address 127.0.0.1:8080 \ + -regex-match +``` + +> :warning: The above feature is experimental. Be careful when using this option, it can expose sensitive metrics by accident. + +To error out when the query already contains a label matcher that differs from the one the proxy would inject, you can use the `-error-on-replace` option. For example: + +``` +prom-label-proxy \ + -header-name X-Namespace \ + -label namespace \ + -upstream http://demo.do.prometheus.io:9090 \ + -insecure-listen-address 127.0.0.1:8080 \ + -error-on-replace +``` + Once again for clarity: **this project only enforces a particular label in the respective calls to Prometheus, it in itself does not authenticate or authorize the requesting entity in any way, this has to be built around this project.** diff --git a/injectproxy/routes.go b/injectproxy/routes.go index 266d3226..4f93f977 100644 --- a/injectproxy/routes.go +++ b/injectproxy/routes.go @@ -46,6 +46,7 @@ type routes struct { mux http.Handler modifiers map[string]func(*http.Response) error errorOnReplace bool + regexMatch bool } type options struct { @@ -53,6 +54,7 @@ type options struct { passthroughPaths []string errorOnReplace bool registerer prometheus.Registerer + regexMatch bool } type Option interface { @@ -96,6 +98,13 @@ func WithErrorOnReplace() Option { }) } +// WithRegexMatch causes the proxy to handle tenant name as regexp +func WithRegexMatch() Option { + return optionFunc(func(o *options) { + o.regexMatch = true + }) +} + // mux abstracts away the behavior we expect from the http.ServeMux type in this package. type mux interface { http.Handler @@ -279,6 +288,7 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o label: label, el: extractLabeler, errorOnReplace: opt.errorOnReplace, + regexMatch: opt.regexMatch, } mux := newStrictMux(newInstrumentedMux(http.NewServeMux(), opt.registerer)) @@ -305,18 +315,21 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o // Reject multi label values with assertSingleLabelValue() because the // semantics of the Silences API don't support multi-label matchers. mux.Handle("/api/v2/silences", r.el.ExtractLabel( - enforceMethods( - assertSingleLabelValue(r.silences), - "GET", "POST", + r.errorIfRegexpMatch( + enforceMethods( + assertSingleLabelValue(r.silences), + "GET", "POST", + ), ), )), mux.Handle("/api/v2/silence/", r.el.ExtractLabel( - enforceMethods( - assertSingleLabelValue(r.deleteSilence), - "DELETE", + r.errorIfRegexpMatch( + enforceMethods( + assertSingleLabelValue(r.deleteSilence), + "DELETE", + ), ), )), - mux.Handle("/api/v2/alerts/groups", r.el.ExtractLabel(enforceMethods(r.enforceFilterParameter, "GET"))), mux.Handle("/api/v2/alerts", r.el.ExtractLabel(enforceMethods(r.alerts, "GET"))), ) @@ -386,6 +399,17 @@ func enforceMethods(h http.HandlerFunc, methods ...string) http.HandlerFunc { } } +func (r *routes) errorIfRegexpMatch(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if r.regexMatch { + prometheusAPIError(w, "support for regex match not implemented", http.StatusNotImplemented) + return + } + + next(w, req) + } +} + type ctxKey int const keyLabel ctxKey = iota @@ -438,16 +462,34 @@ func (r *routes) query(w http.ResponseWriter, req *http.Request) { var matcher *labels.Matcher if len(MustLabelValues(req.Context())) > 1 { + if r.regexMatch { + prometheusAPIError(w, "Only one label value allowed with regex match", http.StatusBadRequest) + return + } matcher = &labels.Matcher{ Name: r.label, Type: labels.MatchRegexp, Value: labelValuesToRegexpString(MustLabelValues(req.Context())), } } else { + matcherType := labels.MatchEqual + matcherValue := MustLabelValue(req.Context()) + if r.regexMatch { + compiledRegex, err := regexp.Compile(matcherValue) + if err != nil { + prometheusAPIError(w, err.Error(), http.StatusBadRequest) + return + } + if compiledRegex.MatchString("") { + prometheusAPIError(w, "Regex should not match empty string", http.StatusBadRequest) + return + } + matcherType = labels.MatchRegexp + } matcher = &labels.Matcher{ Name: r.label, - Type: labels.MatchEqual, - Value: MustLabelValue(req.Context()), + Type: matcherType, + Value: matcherValue, } } diff --git a/injectproxy/routes_test.go b/injectproxy/routes_test.go index 5b114044..84836eb0 100644 --- a/injectproxy/routes_test.go +++ b/injectproxy/routes_test.go @@ -813,6 +813,7 @@ func TestQuery(t *testing.T) { expPromQueryBody string expResponse []byte errorOnReplace bool + regexMatch bool }{ { name: `No "namespace" parameter returns an error`, @@ -1121,6 +1122,60 @@ func TestQuery(t *testing.T) { expPromQuery: `up{instance="localhost:9090",namespace="default"} + foo{namespace="default"}`, expResponse: okResponse, }, + { + name: `HTTP header as regexp`, + headers: http.Header{"namespace": []string{"tenant1-.*"}}, + headerName: "namespace", + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="other"}`, + expCode: http.StatusOK, + expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="other",namespace=~"tenant1-.*"}`, + expResponse: okResponse, + }, + { + name: `query param as regexp`, + queryParam: "namespace", + labelv: []string{"tenant1-.*"}, + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="other"}`, + expCode: http.StatusOK, + expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="other",namespace=~"tenant1-.*"}`, + expResponse: okResponse, + }, + { + name: `HTTP header as regexp with same regexp in query`, + headers: http.Header{"namespace": []string{"tenant1-.*"}}, + headerName: "namespace", + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`, + expCode: http.StatusOK, + expPromQuery: `up{instance="localhost:9090",namespace=~"tenant1-.*"} + foo{namespace="tenant1-.*",namespace=~"tenant1-.*"}`, + expResponse: okResponse, + }, + { + name: `HTTP header with invalid regexp with same regexp in query`, + headers: http.Header{"namespace": []string{"tenant1-(.*"}}, + headerName: "namespace", + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`, + expCode: http.StatusBadRequest, + }, + { + name: `Multiple regexp HTTP headers is invalid`, + headers: http.Header{"namespace": []string{"tenant1", "tenant2"}}, + headerName: "namespace", + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`, + expCode: http.StatusBadRequest, + }, + { + name: `Regex should not match empty string`, + headers: http.Header{"namespace": []string{".*"}}, + headerName: "namespace", + regexMatch: true, + promQuery: `up{instance="localhost:9090"} + foo{namespace="tenant1-.*"}`, + expCode: http.StatusBadRequest, + }, } { for _, endpoint := range []string{"query", "query_range", "query_exemplars"} { t.Run(endpoint+"/"+strings.ReplaceAll(tc.name, " ", "_"), func(t *testing.T) { @@ -1140,6 +1195,9 @@ func TestQuery(t *testing.T) { if tc.errorOnReplace { opts = append(opts, WithErrorOnReplace()) } + if tc.regexMatch { + opts = append(opts, WithRegexMatch()) + } var labelEnforcer ExtractLabeler if len(tc.staticLabelVal) > 0 { diff --git a/injectproxy/rules_test.go b/injectproxy/rules_test.go index deaf04d0..dd81e215 100644 --- a/injectproxy/rules_test.go +++ b/injectproxy/rules_test.go @@ -388,7 +388,7 @@ func TestRules(t *testing.T) { labelv: []string{"not_present_gzip_requested"}, upstream: gzipHandler(validRules()), reqHeaders: map[string][]string{ - "Accept-Encoding": []string{"gzip"}, + "Accept-Encoding": {"gzip"}, }, expCode: http.StatusOK, diff --git a/injectproxy/silences.go b/injectproxy/silences.go index 7a09137d..4f463ff3 100644 --- a/injectproxy/silences.go +++ b/injectproxy/silences.go @@ -21,6 +21,7 @@ import ( "io" "net/http" "path" + "regexp" "strconv" "strings" @@ -73,10 +74,24 @@ func (r *routes) enforceFilterParameter(w http.ResponseWriter, req *http.Request Value: labelValuesToRegexpString(MustLabelValues(req.Context())), } } else { + matcherType := labels.MatchEqual + matcherValue := MustLabelValue(req.Context()) + if r.regexMatch { + compiledRegex, err := regexp.Compile(matcherValue) + if err != nil { + prometheusAPIError(w, err.Error(), http.StatusBadRequest) + return + } + if compiledRegex.MatchString("") { + prometheusAPIError(w, "Regex should not match empty string", http.StatusBadRequest) + return + } + matcherType = labels.MatchRegexp + } proxyLabelMatch = labels.Matcher{ - Type: labels.MatchEqual, + Type: matcherType, Name: r.label, - Value: MustLabelValue(req.Context()), + Value: matcherValue, } } diff --git a/injectproxy/silences_test.go b/injectproxy/silences_test.go index 22d48dd8..6a1e9a6b 100644 --- a/injectproxy/silences_test.go +++ b/injectproxy/silences_test.go @@ -29,8 +29,9 @@ import ( func TestListSilences(t *testing.T) { for _, tc := range []struct { - labelv []string - filters []string + labelv []string + filters []string + regexMatch bool expCode int expFilters []string @@ -74,11 +75,22 @@ func TestListSilences(t *testing.T) { labelv: []string{"default", "something"}, expCode: http.StatusUnprocessableEntity, }, + { + // Regex match + labelv: []string{"tenant1-.*"}, + regexMatch: true, + filters: []string{`namespace=~"foo|default"`, `job="prometheus"`}, + expCode: http.StatusNotImplemented, + }, } { t.Run(strings.Join(tc.filters, "&"), func(t *testing.T) { m := newMockUpstream(checkQueryHandler("", "filter", tc.expFilters...)) defer m.Close() - r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}) + var opts []Option + if tc.regexMatch { + opts = append(opts, WithRegexMatch()) + } + r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}, opts...) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -234,9 +246,10 @@ func (c *chainedHandlers) ServeHTTP(w http.ResponseWriter, req *http.Request) { func TestDeleteSilence(t *testing.T) { for _, tc := range []struct { - ID string - labelv []string - upstream http.Handler + ID string + labelv []string + upstream http.Handler + regexMatch bool expCode int expBody []byte @@ -308,11 +321,21 @@ func TestDeleteSilence(t *testing.T) { labelv: []string{"default", "something"}, expCode: http.StatusUnprocessableEntity, }, + { + // Regexp is not supported. + labelv: []string{"default"}, + regexMatch: true, + expCode: http.StatusNotImplemented, + }, } { t.Run("", func(t *testing.T) { m := newMockUpstream(tc.upstream) defer m.Close() - r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}) + var opts []Option + if tc.regexMatch { + opts = append(opts, WithRegexMatch()) + } + r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}, opts...) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/main.go b/main.go index 9c3520ff..0f6b19cd 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "syscall" @@ -64,6 +65,7 @@ func main() { enableLabelAPIs bool unsafePassthroughPaths string // Comma-delimited string. errorOnReplace bool + regexMatch bool ) flagset := flag.NewFlagSet(os.Args[0], flag.ExitOnError) @@ -81,6 +83,7 @@ func main() { "This option is checked after Prometheus APIs, you cannot override enforced API endpoints to be not enforced with this option. Use carefully as it can easily cause a data leak if the provided path is an important "+ "API (like /api/v1/configuration) which isn't enforced by prom-label-proxy. NOTE: \"all\" matching paths like \"/\" or \"\" and regex are not allowed.") flagset.BoolVar(&errorOnReplace, "error-on-replace", false, "When specified, the proxy will return HTTP status code 400 if the query already contains a label matcher that differs from the one the proxy would inject.") + flagset.BoolVar(®exMatch, "regex-match", false, "When specified, the tenant name is treated as a regular expression. In this case, only one tenant name should be provided.") //nolint: errcheck // Parse() will exit on error. flagset.Parse(os.Args[1:]) @@ -126,6 +129,24 @@ func main() { opts = append(opts, injectproxy.WithErrorOnReplace()) } + if regexMatch { + if len(labelValues) > 0 { + if len(labelValues) > 1 { + log.Fatalf("Regex match is limited to one label value") + } + compiledRegex, err := regexp.Compile(labelValues[0]) + if err != nil { + log.Fatalf("Invalid regexp: %v", err.Error()) + return + } + if compiledRegex.MatchString("") { + log.Fatalf("Regex should not match empty string") + return + } + } + opts = append(opts, injectproxy.WithRegexMatch()) + } + var extractLabeler injectproxy.ExtractLabeler switch { case len(labelValues) > 0: