From f0a27e231ea773f8609df59a5f754f79f5a6e1aa Mon Sep 17 00:00:00 2001 From: Wesley Maffly-Kipp Date: Thu, 20 Aug 2026 15:51:35 -0700 Subject: [PATCH 1/3] pass route-specific predicate exclusions through into our middleware layer --- cmd/api/src/api/middleware/filters.go | 13 +++++++++++-- cmd/api/src/api/router/router.go | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/api/src/api/middleware/filters.go b/cmd/api/src/api/middleware/filters.go index 674688e93cd3..53264b569394 100644 --- a/cmd/api/src/api/middleware/filters.go +++ b/cmd/api/src/api/middleware/filters.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "slices" "github.com/gorilla/mux" @@ -31,18 +32,26 @@ import ( // FilterMiddleware parses query parameter filters from the request, validates them against the supplied // params.Filterable definition, and enriches the BloodHound context with the resulting params.Filters map. +// Query parameters named in additionalIgnoredParameters are skipped during filter parsing but remain +// available on the request. // // When filterable is nil the middleware performs no parsing and passes the request through unchanged. If // the filters are malformed, reference a column that cannot be filtered, or use an operator the column // does not support, the middleware writes a 400 response and halts the chain. -func FilterMiddleware(filterable params.Filterable) mux.MiddlewareFunc { +func FilterMiddleware(filterable params.Filterable, additionalIgnoredParameters ...string) mux.MiddlewareFunc { if filterable == nil { return func(next http.Handler) http.Handler { return next } } - parser := params.NewQueryParameterFilterParser(append(model.IgnoreFilters(), model.AllPaginationQueryParameters()...)...) + ignoredParameters := slices.Concat( + model.IgnoreFilters(), + model.AllPaginationQueryParameters(), + additionalIgnoredParameters, + ) + + parser := params.NewQueryParameterFilterParser(ignoredParameters...) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { diff --git a/cmd/api/src/api/router/router.go b/cmd/api/src/api/router/router.go index 3d89e55be1f8..3f8b59ff08c8 100644 --- a/cmd/api/src/api/router/router.go +++ b/cmd/api/src/api/router/router.go @@ -116,8 +116,9 @@ func (s *Route) CheckFeatureFlag(ff featureFlag, flagKey string) *Route { // WithFilters wires the query parameter filter middleware onto the route, validating any filters against // the supplied params.Filterable definition and enriching the request context with the parsed filters. -func (s *Route) WithFilters(filterable params.Filterable) *Route { - s.handler.Use(middleware.FilterMiddleware(filterable)) +// Query parameters named in additionalIgnoredParameters are skipped during filter parsing. +func (s *Route) WithFilters(filterable params.Filterable, additionalIgnoredParameters ...string) *Route { + s.handler.Use(middleware.FilterMiddleware(filterable, additionalIgnoredParameters...)) return s } From d68975a59d7c85867895783fc2e0077e77d91195 Mon Sep 17 00:00:00 2001 From: Wesley Maffly-Kipp Date: Tue, 25 Aug 2026 11:14:33 -0700 Subject: [PATCH 2/3] add filter middleware tests --- cmd/api/src/api/middleware/filters_test.go | 137 +++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 cmd/api/src/api/middleware/filters_test.go diff --git a/cmd/api/src/api/middleware/filters_test.go b/cmd/api/src/api/middleware/filters_test.go new file mode 100644 index 000000000000..6a8326431fac --- /dev/null +++ b/cmd/api/src/api/middleware/filters_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/specterops/bloodhound/cmd/api/src/api/middleware" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/packages/go/params" + "github.com/stretchr/testify/assert" +) + +type filterTestSchema struct{} + +func (s filterTestSchema) ValidFilters() map[string]params.FilterableField { + equality := []params.FilterOperator{params.Equals, params.NotEquals} + + return map[string]params.FilterableField{ + "foo": {Operators: equality}, + "bar": {Operators: equality}, + } +} + +func TestFilterMiddleware(t *testing.T) { + tests := []struct { + name string + requestTarget string + filterable params.Filterable + additionalIgnoredParameters []string + wantStatus int + wantNextCalled bool + wantQueryParameters url.Values + wantFilters params.Filters + }{ + { + name: "parses a supported filter", + requestTarget: "/endpoint?foo=eq:value", + filterable: filterTestSchema{}, + wantStatus: http.StatusOK, + wantNextCalled: true, + wantFilters: params.Filters{ + "foo": { + { + Field: "foo", + Operator: params.Equals, + Value: "value", + SetOperator: params.FilterAnd, + }, + }, + }, + }, + { + name: "parses a supported filter and skips an ignored filter", + requestTarget: "/endpoint?foo=eq:value&environment_id=tenant:prod", + filterable: filterTestSchema{}, + additionalIgnoredParameters: []string{"environment_id"}, + wantStatus: 200, + wantNextCalled: true, + wantQueryParameters: url.Values{ + "foo": {"eq:value"}, + "environment_id": {"tenant:prod"}, + }, + wantFilters: params.Filters{ + "foo": { + { + Field: "foo", + Operator: params.Equals, + Value: "value", + SetOperator: params.FilterAnd, + }, + }, + }, + }, + { + name: "rejects a non-filterable param", + requestTarget: "/endpoint?baz=eq:value", + filterable: filterTestSchema{}, + wantStatus: 400, + wantNextCalled: false, + }, + { + name: "rejects a malformed predicate", + requestTarget: "/endpoint?bar=invalid:value", + filterable: filterTestSchema{}, + wantStatus: 400, + wantNextCalled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + bloodhoundContext = &bhctx.Context{} + nextCalled = false + ) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + assert.Equal(t, tt.wantFilters, bhctx.Get(r.Context()).Filters) + + if tt.wantQueryParameters != nil { + assert.Equal(t, tt.wantQueryParameters, r.URL.Query()) + } + + w.WriteHeader(http.StatusOK) + }) + + handler := middleware.FilterMiddleware(tt.filterable, tt.additionalIgnoredParameters...)(next) + + request := httptest.NewRequest(http.MethodGet, tt.requestTarget, nil) + request = request.WithContext(bhctx.Set(request.Context(), bloodhoundContext)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + assert.Equal(t, tt.wantNextCalled, nextCalled) + assert.Equal(t, tt.wantStatus, response.Code) + }) + } +} From d0717aff8b7dbcafe0d5dfae0f0ca3b9430c9437 Mon Sep 17 00:00:00 2001 From: Wesley Maffly-Kipp Date: Tue, 25 Aug 2026 16:28:58 -0700 Subject: [PATCH 3/3] just pfc --- cmd/api/src/api/middleware/filters_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api/src/api/middleware/filters_test.go b/cmd/api/src/api/middleware/filters_test.go index 6a8326431fac..14403f05fd12 100644 --- a/cmd/api/src/api/middleware/filters_test.go +++ b/cmd/api/src/api/middleware/filters_test.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS,