fix: widen server-scope boot check, filter out-of-scope key assignments

serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.

GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
This commit is contained in:
2026-09-09 07:51:04 +00:00
parent cbf929fe2d
commit dc6e1b3c29
3 changed files with 61 additions and 13 deletions
+16 -1
View File
@@ -573,7 +573,22 @@ func getKey(c *gin.Context) {
return
}
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
all, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
// A tag-restricted token may legitimately hold a key that is also
// assigned to a server outside its restriction — the key itself is
// still returned above. Only the assignment list is filtered, and
// silently: an assignment whose Server is nil or out of scope is
// dropped rather than kept with the hostname redacted, so the response
// gives no signal — not even a count — of what was removed.
scope := auth.ServerScope(c)
assignments := make([]services.AssignmentWithServer, 0, len(all))
for _, a := range all {
if a.Server != nil && !services.ServerInTokenScope(*a.Server, scope) {
continue
}
assignments = append(assignments, a)
}
c.JSON(http.StatusOK, KeyDetailResponse{
Key: key,
+22 -9
View File
@@ -53,13 +53,25 @@ var serverScopedRoutes = map[string]bool{
// assignKey resolves body.ServerID through GetServerScoped before calling
// services.AssignKey, which itself uses the unscoped GetServer — so a
// restricted token can no longer assign a key to a server outside its
// scope by naming it in the request body. This route's path carries
// neither "server" nor "console", so it is not swept in by
// serverTouchingRoutes and this entry is not boot-enforced; it is kept
// here anyway as the record of a considered decision, matching its
// sibling revoke route.
// scope by naming it in the request body. serverTouchingRoutes in
// cmd/main.go now matches on "assign" as well as "server", so this entry
// is boot-enforced like its sibling revoke route.
"POST /api/keys/:id/assign": true,
// getKey filters services.GetAssignmentsWithServers' result down to
// assignments whose server passes services.ServerInTokenScope(*, before
// returning it, so a restricted token cannot learn the hostname of an
// out-of-scope server through a key it happens to also hold there. The
// key document itself is still returned unfiltered — a token restricted
// to staging may legitimately hold a key that is also assigned in prod,
// and only the assignment list, not the key's existence, is the leak
// this closes. This route's path carries none of "server", ":serverId",
// "console" or "assign", so it is not swept in by serverTouchingRoutes
// and this entry is not boot-enforced — kept anyway as the record of a
// considered decision, same as the assign/revoke routes above before
// their substrings were added to the filter.
"GET /api/keys/:id": true,
// Creating a server has no server to filter yet.
"POST /api/servers": false,
// The agent's own enrolment routes authenticate as the agent, not as a
@@ -77,10 +89,11 @@ var serverScopedRoutes = map[string]bool{
}
// POST/GET /api/mcp is deliberately absent from this map. main.go's
// serverTouchingRoutes only feeds in routes whose path contains "server" or
// "console" (or the one named workflow-run exception), and /api/mcp matches
// none of those, so it is never presented to AssertServerScopeMapComplete —
// there is nothing to declare true or false here. That is the right outcome:
// serverTouchingRoutes only feeds in routes whose path contains "server",
// ":serverId", "console" or "assign" (or the one named workflow-run
// exception), and /api/mcp matches none of those, so it is never presented to
// AssertServerScopeMapComplete — there is nothing to declare true or false
// here. That is the right outcome:
// the single MCP route fronts many tools of very different shapes, several of
// which touch no server data at all, so a route-level entry could not say
// anything meaningful about tag scoping. Each tool that does read or act on