Skip to content

Commit

Permalink
frontend: Allow blocking raw http requests (#10484)
Browse files Browse the repository at this point in the history
* frontend: Allow blocking raw http requests
This is a setting that can be set in user overrides like this:
```
user1:
  blocked_requests:
    - path: /api/v1/series
    - method: DELETE
    - query_params
         foo: bar
```

Or a combination of these. Each entry is an AND condition

* Add Roundtripper test

* linting...

* Allow for regexps on the query params + add changelog

* Add test for unmarshalling

* Fix linting

(cherry picked from commit 557830d)
  • Loading branch information
julienduchesne committed Jan 21, 2025
1 parent d5e7264 commit d8c9a77
Show file tree
Hide file tree
Showing 17 changed files with 462 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* [ENHANCEMENT] Dashboards: Add Query-Scheduler <-> Querier Inflight Requests row to Query Reads and Remote Ruler reads dashboards. #10290
* [ENHANCEMENT] OTLP: In addition to the flag `-distributor.otel-created-timestamp-zero-ingestion-enabled` there is now `-distributor.otel-start-time-quiet-zero` to convert OTel start timestamps to Prometheus QuietZeroNaNs. This flag is to make the change rollout safe between Ingesters and Distributors. #10238
* [ENHANCEMENT] Ruler: When rule concurrency is enabled for a rule group, its rules will now be reordered and run in batches based on their dependencies. This increases the number of rules that can potentially run concurrently. Note that the global and tenant-specific limits still apply #10400
* [ENHANCEMENT] Query-frontend: Allow blocking raw http requests with the `blocked_requests` configuration. Requests can be blocked based on their path, method
* [BUGFIX] Distributor: Use a boolean to track changes while merging the ReplicaDesc components, rather than comparing the objects directly. #10185
* [BUGFIX] Querier: fix timeout responding to query-frontend when response size is very close to `-querier.frontend-client.grpc-max-send-msg-size`. #10154
* [BUGFIX] Query-frontend and querier: show warning/info annotations in some cases where they were missing (if a lazy querier was used). #10277
Expand Down
10 changes: 10 additions & 0 deletions cmd/mimir/config-descriptor.json
Original file line number Diff line number Diff line change
Expand Up @@ -4306,6 +4306,16 @@
"fieldType": "blocked_queries_config...",
"fieldCategory": "experimental"
},
{
"kind": "field",
"name": "blocked_requests",
"required": false,
"desc": "List of http requests to block.",
"fieldValue": null,
"fieldDefaultValue": null,
"fieldType": "blocked_requests_config...",
"fieldCategory": "experimental"
},
{
"kind": "field",
"name": "align_queries_with_step",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3542,6 +3542,9 @@ The `limits` block configures default and per-tenant limits imposed by component
# (experimental) List of queries to block.
[blocked_queries: <blocked_queries_config...> | default = ]

# (experimental) List of http requests to block.
[blocked_requests: <blocked_requests_config...> | default = ]

# Mutate incoming queries to align their start and end with their step to
# improve result caching.
# CLI flag: -query-frontend.align-queries-with-step
Expand Down
13 changes: 13 additions & 0 deletions docs/sources/mimir/manage/mimir-runbooks/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,19 @@ How to **fix** it:
This error only occurs when an administrator has explicitly define a blocked list for a given tenant. After assessing whether or not the reason for blocking one or multiple queries you can update the tenant's limits and remove the pattern.
### err-mimir-request-blocked
This error occurs when a query-frontend blocks a HTTP request because the request matches at least one of the rules defined in the limits.
How it **works**:
- The query-frontend implements a checker responsible for assessing whether the request is blocked or not.
- To configure the limit, set the block `blocked_requests` in the `limits`.
How to **fix** it:
This error only occurs when an administrator has explicitly define a blocked list for a given tenant. After assessing whether or not the reason for blocking one or multiple requests you can update the tenant's limits and remove the configuration.
### err-mimir-alertmanager-max-grafana-config-size
This non-critical error occurs when the Alertmanager receives a Grafana Alertmanager configuration larger than the configured size limit.
Expand Down
3 changes: 3 additions & 0 deletions pkg/frontend/querymiddleware/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ type Limits interface {
// BlockedQueries returns the blocked queries.
BlockedQueries(userID string) []*validation.BlockedQuery

// BlockedRequests returns the blocked http requests.
BlockedRequests(userID string) []*validation.BlockedRequest

// AlignQueriesWithStep returns if queries should be adjusted to be step-aligned
AlignQueriesWithStep(userID string) bool

Expand Down
9 changes: 9 additions & 0 deletions pkg/frontend/querymiddleware/limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,10 @@ func (m multiTenantMockLimits) IngestStorageReadConsistency(userID string) strin
return m.byTenant[userID].ingestStorageReadConsistency
}

func (m multiTenantMockLimits) BlockedRequests(userID string) []*validation.BlockedRequest {
return m.byTenant[userID].blockedRequests
}

type mockLimits struct {
maxQueryLookback time.Duration
maxQueryLength time.Duration
Expand All @@ -626,6 +630,7 @@ type mockLimits struct {
enabledPromQLExperimentalFunctions []string
prom2RangeCompat bool
blockedQueries []*validation.BlockedQuery
blockedRequests []*validation.BlockedRequest
alignQueriesWithStep bool
queryIngestersWithin time.Duration
ingestStorageReadConsistency string
Expand Down Expand Up @@ -741,6 +746,10 @@ func (m mockLimits) IngestStorageReadConsistency(string) string {
return m.ingestStorageReadConsistency
}

func (m mockLimits) BlockedRequests(string) []*validation.BlockedRequest {
return m.blockedRequests
}

type mockHandler struct {
mock.Mock
}
Expand Down
File renamed without changes.
96 changes: 96 additions & 0 deletions pkg/frontend/querymiddleware/request_blocker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-only

package querymiddleware

import (
"net/http"

"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/grafana/dskit/tenant"
"github.com/grafana/regexp"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"

apierror "github.com/grafana/mimir/pkg/api/error"
"github.com/grafana/mimir/pkg/util/globalerror"
)

func newRequestBlockedError() error {
return apierror.New(apierror.TypeBadData, globalerror.RequestBlocked.Message("the request has been blocked by the cluster administrator"))
}

type requestBlocker struct {
limits Limits
logger log.Logger
blockedRequestsCounter *prometheus.CounterVec
}

func newRequestBlocker(
limits Limits,
logger log.Logger,
registerer prometheus.Registerer,
) *requestBlocker {
blockedRequestsCounter := promauto.With(registerer).NewCounterVec(prometheus.CounterOpts{
Name: "cortex_query_frontend_rejected_requests_total",
Help: "Number of HTTP requests that were rejected by the cluster administrator.",
}, []string{"user"})
return &requestBlocker{
limits: limits,
logger: logger,
blockedRequestsCounter: blockedRequestsCounter,
}
}

func (rb *requestBlocker) isBlocked(r *http.Request) error {
tenants, err := tenant.TenantIDs(r.Context())
if err != nil {
return nil
}

for _, tenant := range tenants {
blockedRequests := rb.limits.BlockedRequests(tenant)

for _, blockedRequest := range blockedRequests {
if blockedPath := blockedRequest.Path; blockedPath != "" && blockedPath != r.URL.Path {
continue
}

if blockedMethod := blockedRequest.Method; blockedMethod != "" && blockedMethod != r.Method {
continue
}

if blockedParams := blockedRequest.QueryParams; len(blockedParams) > 0 {
query := r.URL.Query()
blockedByParams := false
for key, blocked := range blockedParams {
if blocked.IsRegexp {
blockedRegexp, err := regexp.Compile(blocked.Value)
if err != nil {
level.Error(rb.logger).Log("msg", "failed to compile regexp. Not blocking", "regexp", blocked.Value, "err", err)
continue
}

if blockedRegexp.MatchString(query.Get(key)) {
blockedByParams = true
break
}
} else if query.Get(key) == blocked.Value {
blockedByParams = true
break
}
}

if !blockedByParams {
continue
}
}

level.Info(rb.logger).Log("msg", "request blocked", "user", tenant, "url", r.URL.String(), "method", r.Method)
rb.blockedRequestsCounter.WithLabelValues(tenant).Inc()
return newRequestBlockedError()
}
}
return nil

}
157 changes: 157 additions & 0 deletions pkg/frontend/querymiddleware/request_blocker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-only

package querymiddleware

import (
"context"
"net/http"
"testing"

"github.com/go-kit/log"
"github.com/grafana/dskit/user"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/grafana/mimir/pkg/util/validation"
)

func TestRequestBlocker_IsBlocked(t *testing.T) {
const userID = "user-1"
// Mock the limits.
limits := multiTenantMockLimits{
byTenant: map[string]mockLimits{
userID: {
blockedRequests: []*validation.BlockedRequest{
{Path: "/blocked-by-path"},
{Method: "POST"},
{
QueryParams: map[string]validation.BlockedRequestQueryParam{"foo": {Value: "bar"}},
},
{
Path: "/blocked-by-path2", Method: "GET",
QueryParams: map[string]validation.BlockedRequestQueryParam{"foo": {Value: "bar2"}},
},
{
Path: "/block-by-query-regexp", Method: "GET",
QueryParams: map[string]validation.BlockedRequestQueryParam{"foo": {Value: ".*hello.*", IsRegexp: true}},
},
// Invalid regexp should not block anything.
{
QueryParams: map[string]validation.BlockedRequestQueryParam{"foo": {Value: "\bar", IsRegexp: true}},
},
},
},
},
}

tests := []struct {
name string
request func() *http.Request
expected error
}{
{
name: "request is not blocked",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/not-blocked", nil)
require.NoError(t, err)
return req
},
expected: nil,
},
{
name: "request is blocked by path",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/blocked-by-path", nil)
require.NoError(t, err)
return req
},
expected: newRequestBlockedError(),
},
{
name: "request is blocked by method",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodPost, "/not-blocked", nil)
require.NoError(t, err)
return req
},
expected: newRequestBlockedError(),
},
{
name: "request is blocked by query params",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/not-blocked?foo=bar", nil)
require.NoError(t, err)
return req
},
expected: newRequestBlockedError(),
},
{
name: "request is blocked by path, method and query params",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/blocked-by-path2?foo=bar2", nil)
require.NoError(t, err)
return req
},
expected: newRequestBlockedError(),
},
{
name: "request does not fully match blocked request (different method)",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodDelete, "/blocked-by-path2?foo=bar2", nil)
require.NoError(t, err)
return req
},
expected: nil,
},
{
name: "request does not fully match blocked request (different query params)",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/blocked-by-path2?foo=bar3", nil)
require.NoError(t, err)
return req
},
expected: nil,
},
{
name: "request does not fully match blocked request (different path)",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/blocked-by-path3?foo=bar2", nil)
require.NoError(t, err)
return req
},
expected: nil,
},
{
name: "regexp does not match",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/block-by-query-regexp?foo=test", nil)
require.NoError(t, err)
return req
},
expected: nil,
},
{
name: "regexp matches",
request: func() *http.Request {
req, err := http.NewRequest(http.MethodGet, "/block-by-query-regexp?foo=my-value-hello-test", nil)
require.NoError(t, err)
return req
},
expected: newRequestBlockedError(),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
blocker := newRequestBlocker(limits, log.NewNopLogger(), prometheus.NewRegistry())

req := tt.request()
ctx := user.InjectOrgID(context.Background(), userID)
req = req.WithContext(ctx)

err := blocker.isBlocked(req)
assert.Equal(t, err, tt.expected)
})
}
}
5 changes: 5 additions & 0 deletions pkg/frontend/querymiddleware/roundtrip.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ func newQueryTripperware(
}

queryRangeMiddleware, queryInstantMiddleware, remoteReadMiddleware := newQueryMiddlewares(cfg, log, limits, codec, c, cacheKeyGenerator, cacheExtractor, engine, registerer)
requestBlocker := newRequestBlocker(limits, log, registerer)

return func(next http.RoundTripper) http.RoundTripper {
// IMPORTANT: roundtrippers are executed in *reverse* order because they are wrappers.
Expand Down Expand Up @@ -311,6 +312,10 @@ func newQueryTripperware(
cardinality = NewCardinalityQueryRequestValidationRoundTripper(cardinality)

return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
if err := requestBlocker.isBlocked(r); err != nil {
return nil, err
}

switch {
case IsRangeQuery(r.URL.Path):
return queryrange.RoundTrip(r)
Expand Down
Loading

0 comments on commit d8c9a77

Please sign in to comment.