diff --git a/cmd/api/src/api/middleware/sort.go b/cmd/api/src/api/middleware/sort.go new file mode 100644 index 000000000000..5f6a075ed19a --- /dev/null +++ b/cmd/api/src/api/middleware/sort.go @@ -0,0 +1,75 @@ +// 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 + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/packages/go/sorts" +) + +// SortMiddleware parses the sort_by query parameters from the request, validates them against the supplied +// sort.Sortable definition, and enriches the BloodHound context with the resulting sort.SortItems. +// +// When sortable is nil the middleware performs no parsing and passes the request through unchanged. If a +// sort field is empty or references a field that cannot be sorted, the middleware writes a 400 response +// and halts the chain. +func SortMiddleware(sortable sorts.Sortable) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if sortable == nil { + next.ServeHTTP(response, request) + return + } + + parser := sorts.NewQueryParameterSortParser() + if parsedSort, err := parser.ParseAndValidate(request.URL.Query(), sortable); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, sortErrorMessage(err), request), response) + return + } else { + bhCtx := bhctx.Get(request.Context()) + bhCtx.Sort = parsedSort + } + + next.ServeHTTP(response, request) + }) + } +} + +// sortErrorMessage maps a sort validation failure to the appropriate API error response detail, preserving +// the offending field in the message where available. +func sortErrorMessage(err error) string { + var validationErr *sorts.ValidationError + if !errors.As(err, &validationErr) { + return api.ErrorResponseDetailsNotSortable + } + + switch { + case errors.Is(validationErr, sorts.ErrFieldEmpty): + return api.ErrorResponseEmptySortParameter + case errors.Is(validationErr, sorts.ErrFieldNotSortable): + return fmt.Sprintf("%s: %s", api.ErrorResponseDetailsNotSortable, validationErr.Field) + default: + return api.ErrorResponseDetailsNotSortable + } +} diff --git a/cmd/api/src/api/router/router.go b/cmd/api/src/api/router/router.go index 0c801dfd77bf..23fdf86c1d07 100644 --- a/cmd/api/src/api/router/router.go +++ b/cmd/api/src/api/router/router.go @@ -28,6 +28,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/packages/go/filters" + "github.com/specterops/bloodhound/packages/go/sorts" ) // With takes a function returning a mux.MiddlewareFunc type and applies it the to variadic list of routes @@ -121,6 +122,13 @@ func (s *Route) WithFilters(filterable filters.Filterable) *Route { return s } +// WithSort wires the query parameter sort middleware onto the route, validating any sort columns against +// the supplied sort.Sortable definition and enriching the request context with the parsed sort items. +func (s *Route) WithSort(sortable sorts.Sortable) *Route { + s.handler.Use(middleware.SortMiddleware(sortable)) + return s +} + func NewRouter(cfg config.Configuration, authorizer auth.Authorizer, contentSecurityPolicy string) Router { muxRouter := mux.NewRouter() muxRouter.Use(middleware.EnsureRequestBodyClosed()) diff --git a/cmd/api/src/bhctx/bhctx.go b/cmd/api/src/bhctx/bhctx.go index e3093823aaf9..4d07d479b977 100644 --- a/cmd/api/src/bhctx/bhctx.go +++ b/cmd/api/src/bhctx/bhctx.go @@ -26,6 +26,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/auth" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/packages/go/filters" + "github.com/specterops/bloodhound/packages/go/sorts" ) // Use our own type rather than a primitive to avoid collisions @@ -45,6 +46,7 @@ type Context struct { RequestIP string RemoteAddr string Filters filters.Filters + Sort sorts.SortItems } func (s *Context) ConstructGoContext() context.Context { diff --git a/packages/go/sorts/parser.go b/packages/go/sorts/parser.go new file mode 100644 index 000000000000..0fd0e59706e4 --- /dev/null +++ b/packages/go/sorts/parser.go @@ -0,0 +1,64 @@ +// 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 sorts + +import ( + "net/url" + "strings" +) + +// QueryParameter is the query parameter name from which sort fields are read. +const QueryParameter = "sort_by" + +// QueryParameterSortParser extracts sort items from request query parameters. +type QueryParameterSortParser struct{} + +// NewQueryParameterSortParser returns a parser ready to parse and validate sort query parameters. +func NewQueryParameterSortParser() QueryParameterSortParser { + return QueryParameterSortParser{} +} + +// ParseAndValidate parses the sort_by query parameters and validates them against the Sortable schema in a +// single pass, returning an ordered SortItems value. A leading "-" selects a descending ordering for the +// referenced field; its absence selects an ascending ordering. On failure it returns a *ValidationError +// wrapping one of the validation sentinels. +func (s QueryParameterSortParser) ParseAndValidate(values url.Values, sortable Sortable) (SortItems, error) { + var ( + requestedFields = values[QueryParameter] + parsedSort = make(SortItems, 0, len(requestedFields)) + ) + + for _, requestedField := range requestedFields { + if requestedField == "" || requestedField == "-" { + return nil, &ValidationError{Err: ErrFieldEmpty} + } + + sortItem := SortItem{Field: requestedField, Direction: Ascending} + if strings.HasPrefix(requestedField, DescendingPrefix) { + sortItem.Field = strings.TrimPrefix(requestedField, DescendingPrefix) + sortItem.Direction = Descending + } + + if !sortable.IsSortable(sortItem.Field) { + return nil, &ValidationError{Err: ErrFieldNotSortable, Field: sortItem.Field} + } + + parsedSort = append(parsedSort, sortItem) + } + + return parsedSort, nil +} diff --git a/packages/go/sorts/parser_test.go b/packages/go/sorts/parser_test.go new file mode 100644 index 000000000000..a692847b40ca --- /dev/null +++ b/packages/go/sorts/parser_test.go @@ -0,0 +1,105 @@ +// 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 sorts_test + +import ( + "net/url" + "testing" + + "github.com/specterops/bloodhound/packages/go/sorts" + "github.com/stretchr/testify/require" +) + +// fakeSortable is a minimal Sortable used to drive ParseAndValidate without coupling the test to any +// persistence layer. +type fakeSortable struct { + sortableFields map[string]bool +} + +func (s fakeSortable) IsSortable(field string) bool { + return s.sortableFields[field] +} + +func TestParseAndValidate_Parsing(t *testing.T) { + var ( + parser = sorts.NewQueryParameterSortParser() + sortable = fakeSortable{sortableFields: map[string]bool{"objectid": true, "name": true}} + ) + + // These cases mirror the legacy api sort parser tests to vet behavioral parity. + t.Run("parses an ascending field", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"sort_by": {"objectid"}}, sortable) + require.NoError(t, err) + require.Len(t, parsed, 1) + require.Equal(t, "objectid", parsed[0].Field) + require.Equal(t, sorts.Ascending, parsed[0].Direction) + }) + + t.Run("parses a descending field denoted by a leading -", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"sort_by": {"-objectid"}}, sortable) + require.NoError(t, err) + require.Len(t, parsed, 1) + require.Equal(t, "objectid", parsed[0].Field) + require.Equal(t, sorts.Descending, parsed[0].Direction) + }) + + t.Run("parses multiple fields in order", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"sort_by": {"objectid", "-name"}}, sortable) + require.NoError(t, err) + require.Len(t, parsed, 2) + require.Equal(t, "objectid", parsed[0].Field) + require.Equal(t, sorts.Ascending, parsed[0].Direction) + require.Equal(t, "name", parsed[1].Field) + require.Equal(t, sorts.Descending, parsed[1].Direction) + }) + + t.Run("returns an empty result when no sort_by parameter is supplied", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{}, sortable) + require.NoError(t, err) + require.Empty(t, parsed) + }) +} + +func TestParseAndValidate_Errors(t *testing.T) { + var ( + parser = sorts.NewQueryParameterSortParser() + sortable = fakeSortable{sortableFields: map[string]bool{"objectid": true}} + ) + + t.Run("ErrFieldNotSortable for an unsortable field, preserving the offending field", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"sort_by": {"invalidField"}}, sortable) + require.ErrorIs(t, err, sorts.ErrFieldNotSortable) + + var validationError *sorts.ValidationError + require.ErrorAs(t, err, &validationError) + require.Equal(t, "invalidField", validationError.Field) + }) + + t.Run("ErrFieldNotSortable applies to the field with its descending prefix stripped", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"sort_by": {"-invalidField"}}, sortable) + require.ErrorIs(t, err, sorts.ErrFieldNotSortable) + + var validationError *sorts.ValidationError + require.ErrorAs(t, err, &validationError) + require.Equal(t, "invalidField", validationError.Field) + }) + + t.Run("ErrFieldEmpty for an empty sort_by value", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"sort_by": {""}}, sortable) + require.ErrorIs(t, err, sorts.ErrFieldEmpty) + }) +} diff --git a/packages/go/sorts/sort.go b/packages/go/sorts/sort.go new file mode 100644 index 000000000000..e5e2a772e580 --- /dev/null +++ b/packages/go/sorts/sort.go @@ -0,0 +1,79 @@ +// 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 sort provides a storage-agnostic model for parsing and validating sort query parameters. +// It intentionally carries no knowledge of how the resulting order is ultimately applied (SQL, graph, +// etc.) so that it can be reused across the API without coupling to any particular persistence layer. +package sorts + +import ( + "errors" + "fmt" +) + +// SortDirection identifies the ordering applied to a sorted field. +type SortDirection string + +const ( + Ascending SortDirection = "asc" + Descending SortDirection = "desc" +) + +// DescendingPrefix is the leading character on a sort_by value that selects a descending ordering for the +// referenced field. Its absence selects an ascending ordering. +const DescendingPrefix = "-" + +// Validation sentinels classify why a set of sort query parameters failed validation. Callers should +// classify failures with errors.Is and may extract the offending field via a *ValidationError using +// errors.As. These are intentionally free of any transport- or presentation-specific wording. +var ( + ErrFieldEmpty = errors.New("sort column cannot be empty") + ErrFieldNotSortable = errors.New("column cannot be sorted") +) + +// ValidationError describes a sort validation failure. It wraps one of the validation sentinels so it can +// be classified with errors.Is, while also carrying the offending field so callers can build their own +// messaging without parsing strings. +type ValidationError struct { + Err error + Field string +} + +func (s *ValidationError) Error() string { + if s.Field != "" { + return fmt.Sprintf("%s: %s", s.Err, s.Field) + } + + return s.Err.Error() +} + +func (s *ValidationError) Unwrap() error { + return s.Err +} + +// SortItem is a single validated sort applied to a field. +type SortItem struct { + Field string + Direction SortDirection +} + +// SortItems is an ordered set of SortItem values, preserving the order in which the fields were requested. +type SortItems []SortItem + +// Sortable is implemented by types that expose which fields may be sorted on. +type Sortable interface { + IsSortable(field string) bool +}