2026-04-10 15:12:38 +00:00
|
|
|
package apiendpoints
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
2026-05-04 16:47:57 +00:00
|
|
|
"regexp"
|
2026-04-10 15:12:38 +00:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestValidateAPIEndpoints(t *testing.T) {
|
|
|
|
|
originalYAML := apiEndpointsYAML
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
apiEndpointsYAML = originalYAML
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
endpoints := []fleet.APIEndpoint{
|
|
|
|
|
fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts"),
|
|
|
|
|
fleet.NewAPIEndpointFromTpl("POST", "/api/v1/fleet/hosts/:id/refetch"),
|
|
|
|
|
}
|
|
|
|
|
apiEndpointsYAML = []byte(`
|
|
|
|
|
- method: "GET"
|
|
|
|
|
path: "/api/v1/fleet/hosts"
|
|
|
|
|
display_name: "Route 1"
|
|
|
|
|
- method: "POST"
|
|
|
|
|
path: "/api/v1/fleet/hosts/:id/refetch"
|
|
|
|
|
display_name: "Route 2"`)
|
|
|
|
|
|
|
|
|
|
routerWithEndpoints := func(endpoints []fleet.APIEndpoint) *mux.Router {
|
|
|
|
|
r := mux.NewRouter()
|
|
|
|
|
for _, e := range endpoints {
|
|
|
|
|
path := strings.Replace(e.Path, "/_version_/", "/{fleetversion:(?:v1|latest)}/", 1)
|
|
|
|
|
r.Handle(path, http.NotFoundHandler()).Methods(e.Method)
|
|
|
|
|
}
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Run("all routes present", func(t *testing.T) {
|
|
|
|
|
err := Init(routerWithEndpoints(endpoints))
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("missing route returns error", func(t *testing.T) {
|
|
|
|
|
err := Init(routerWithEndpoints(endpoints[:1]))
|
|
|
|
|
require.ErrorContains(t, err, endpoints[1].Method+" "+endpoints[1].Path)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("no routes registered returns error listing all missing", func(t *testing.T) {
|
|
|
|
|
err := Init(mux.NewRouter())
|
|
|
|
|
require.ErrorContains(t, err, "the following API endpoints are unknown")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("non-mux handler returns error", func(t *testing.T) {
|
|
|
|
|
err := Init(http.NewServeMux())
|
|
|
|
|
require.ErrorContains(t, err, "expected *mux.Router")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("empty endpoint list always passes", func(t *testing.T) {
|
|
|
|
|
apiEndpointsYAML = []byte(``)
|
|
|
|
|
err := Init(mux.NewRouter())
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-04 16:47:57 +00:00
|
|
|
|
|
|
|
|
// catalogBlocklistRule is a rule used by TestCatalogBlocklist to forbid
|
|
|
|
|
// catalog entries matching (method, path).
|
|
|
|
|
type catalogBlocklistRule struct {
|
|
|
|
|
method string
|
|
|
|
|
path *regexp.Regexp // matched against the catalog entry's Path; anchor with ^...$
|
|
|
|
|
reason string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// catalogBlocklistRules guards against catalog drift: endpoints that would let
|
|
|
|
|
// an api_only user with a restrictive allowlist bypass their own restrictions
|
|
|
|
|
// must never be added to api_endpoints.yml.
|
|
|
|
|
//
|
|
|
|
|
// The allowlist is a narrowing layer over RBAC, so endpoints that are simply
|
|
|
|
|
// destructive (e.g. DELETE /sessions/:id, DELETE /users/:id) are still gated
|
|
|
|
|
// by RBAC and are NOT covered here. The patterns below cover cases where
|
|
|
|
|
// adding the endpoint to the catalog would let the holder circumvent the
|
|
|
|
|
// allowlist itself:
|
|
|
|
|
//
|
|
|
|
|
// - User-creation endpoints that return a session token in the response —
|
|
|
|
|
// a restricted api_only user could mint a clone of themselves with no
|
|
|
|
|
// allowlist and use the returned token to operate without restrictions.
|
|
|
|
|
// - User-modification endpoints that touch the api_endpoints field —
|
|
|
|
|
// direct allowlist broadening (self-modify is blocked, but any future
|
|
|
|
|
// code change that loosens that check would expose this).
|
|
|
|
|
// - Invite creation/modification — same as user creation, mints a new
|
|
|
|
|
// account with a chosen role.
|
|
|
|
|
// - Bulk role-spec apply (POST /users/roles/spec) — has a coarser authz
|
|
|
|
|
// check than the per-user modify path (no per-target ActionWriteRole gate).
|
|
|
|
|
//
|
|
|
|
|
// If you are intentionally exposing a new endpoint that matches one of these
|
|
|
|
|
// patterns, justify the change in a security review and update the rules.
|
|
|
|
|
var catalogBlocklistRules = []catalogBlocklistRule{
|
|
|
|
|
{"POST", regexp.MustCompile(`^/api/v1/fleet/users/roles/spec$`), "bulk role-spec apply lacks per-target ActionWriteRole gate"},
|
|
|
|
|
{"POST", regexp.MustCompile(`^/api/v1/fleet/users(?:/.*)?$`), "user creation can return a session token (allowlist bypass via clone)"},
|
|
|
|
|
{"PATCH", regexp.MustCompile(`^/api/v1/fleet/users(?:/.*)?$`), "user modification can change the api_endpoints allowlist"},
|
|
|
|
|
{"POST", regexp.MustCompile(`^/api/v1/fleet/invites(?:/.*)?$`), "invite creation = mint user with chosen role"},
|
|
|
|
|
{"PATCH", regexp.MustCompile(`^/api/v1/fleet/invites(?:/.*)?$`), "invite modification = change role on a pending user"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findBlocklistViolations(endpoints []fleet.APIEndpoint) []string {
|
|
|
|
|
var msgs []string
|
|
|
|
|
for _, ep := range endpoints {
|
|
|
|
|
for _, r := range catalogBlocklistRules {
|
|
|
|
|
if strings.EqualFold(ep.Method, r.method) && r.path.MatchString(ep.Path) {
|
|
|
|
|
msgs = append(msgs, " "+ep.Method+" "+ep.Path+" — "+r.reason)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return msgs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCatalogBlocklist(t *testing.T) {
|
|
|
|
|
t.Run("current catalog is clean", func(t *testing.T) {
|
|
|
|
|
loaded, err := loadAPIEndpoints()
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
violations := findBlocklistViolations(loaded)
|
|
|
|
|
if len(violations) > 0 {
|
|
|
|
|
t.Fatalf("api_endpoints.yml contains forbidden routes (would let an api_only user bypass their allowlist):\n%s\n\n"+
|
|
|
|
|
"If this is intentional, justify the change in a security review and update catalogBlocklistRules.",
|
|
|
|
|
strings.Join(violations, "\n"))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("rules catch each forbidden pattern", func(t *testing.T) {
|
|
|
|
|
// Fault-injection: every forbidden pattern must produce a violation.
|
|
|
|
|
// Failure here means a rule was deleted or its regex no longer matches
|
|
|
|
|
// the example path it was meant to cover.
|
|
|
|
|
examples := []fleet.APIEndpoint{
|
|
|
|
|
{Method: "POST", Path: "/api/v1/fleet/users/roles/spec"},
|
|
|
|
|
{Method: "POST", Path: "/api/v1/fleet/users"},
|
|
|
|
|
{Method: "POST", Path: "/api/v1/fleet/users/admin"},
|
|
|
|
|
{Method: "POST", Path: "/api/v1/fleet/users/api_only"},
|
|
|
|
|
{Method: "PATCH", Path: "/api/v1/fleet/users/:id"},
|
|
|
|
|
{Method: "PATCH", Path: "/api/v1/fleet/users/api_only/:id"},
|
|
|
|
|
{Method: "POST", Path: "/api/v1/fleet/invites"},
|
|
|
|
|
{Method: "PATCH", Path: "/api/v1/fleet/invites/:id"},
|
|
|
|
|
}
|
|
|
|
|
for _, ex := range examples {
|
|
|
|
|
t.Run(ex.Method+" "+ex.Path, func(t *testing.T) {
|
|
|
|
|
violations := findBlocklistViolations([]fleet.APIEndpoint{ex})
|
|
|
|
|
require.Len(t, violations, 1, "expected this endpoint to be blocked but the rules let it through")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("rules do not flag legitimate catalog entries", func(t *testing.T) {
|
|
|
|
|
// Sanity check: read-only entries on the same resources stay allowed.
|
|
|
|
|
ok := []fleet.APIEndpoint{
|
|
|
|
|
{Method: "GET", Path: "/api/v1/fleet/users"},
|
|
|
|
|
{Method: "GET", Path: "/api/v1/fleet/users/:id"},
|
|
|
|
|
{Method: "GET", Path: "/api/v1/fleet/sessions/:id"},
|
|
|
|
|
{Method: "DELETE", Path: "/api/v1/fleet/sessions/:id"},
|
|
|
|
|
}
|
|
|
|
|
violations := findBlocklistViolations(ok)
|
|
|
|
|
require.Empty(t, violations, "expected these entries to pass: %v", violations)
|
|
|
|
|
})
|
|
|
|
|
}
|