Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions cmd/api/src/api/middleware/sort.go
Original file line number Diff line number Diff line change
@@ -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/sort"
)

// 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 sort.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 := sort.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 *sort.ValidationError
if !errors.As(err, &validationErr) {
return api.ErrorResponseDetailsNotSortable
}

switch {
case errors.Is(validationErr, sort.ErrFieldEmpty):
return api.ErrorResponseEmptySortParameter
case errors.Is(validationErr, sort.ErrFieldNotSortable):
return fmt.Sprintf("%s: %s", api.ErrorResponseDetailsNotSortable, validationErr.Field)
default:
return api.ErrorResponseDetailsNotSortable
}
}
8 changes: 8 additions & 0 deletions cmd/api/src/api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/specterops/bloodhound/cmd/api/src/database"
"github.com/specterops/bloodhound/cmd/api/src/model"
"github.com/specterops/bloodhound/cmd/api/src/services/dogtags"
"github.com/specterops/bloodhound/packages/go/sort"
)

// With takes a function returning a mux.MiddlewareFunc type and applies it the to variadic list of routes
Expand Down Expand Up @@ -113,6 +114,13 @@ func (s *Route) CheckFeatureFlag(ff featureFlag, flagKey string) *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 sort.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())
Expand Down
2 changes: 2 additions & 0 deletions cmd/api/src/bhctx/bhctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"github.com/specterops/bloodhound/cmd/api/src/auth"
"github.com/specterops/bloodhound/cmd/api/src/model"
"github.com/specterops/bloodhound/packages/go/sort"
)

// Use our own type rather than a primitive to avoid collisions
Expand All @@ -43,6 +44,7 @@ type Context struct {
RequestedURL model.AuditableURL
RequestIP string
RemoteAddr string
Sort sort.SortItems
}

func (s *Context) ConstructGoContext() context.Context {
Expand Down
64 changes: 64 additions & 0 deletions packages/go/sort/parser.go
Original file line number Diff line number Diff line change
@@ -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 sort

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 == "" {
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
}
105 changes: 105 additions & 0 deletions packages/go/sort/parser_test.go
Original file line number Diff line number Diff line change
@@ -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 sort_test

import (
"net/url"
"testing"

"github.com/specterops/bloodhound/packages/go/sort"
"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 = sort.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, sort.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, sort.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, sort.Ascending, parsed[0].Direction)
require.Equal(t, "name", parsed[1].Field)
require.Equal(t, sort.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 = sort.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, sort.ErrFieldNotSortable)

var validationError *sort.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, sort.ErrFieldNotSortable)

var validationError *sort.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, sort.ErrFieldEmpty)
})
}
79 changes: 79 additions & 0 deletions packages/go/sort/sort.go
Original file line number Diff line number Diff line change
@@ -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 sort

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
}
Loading